wannpassts/backend/internal/db/db.go
Tronax cb30223fd4
feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app:

- Go backend with JWT auth, SQLite storage, and chi router
- Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links
- Public booking pages with configurable slots and booking requests
- AES-256-GCM encryption for stored provider tokens
- Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router
- Docs, .gitignore, and .env.example for local development
2026-08-25 18:50:28 +02:00

354 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
import (
"database/sql"
"errors"
"time"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("nicht gefunden")
type DB struct {
*sql.DB
}
type User struct {
ID int64
Email string
Name string
PasswordHash string
Slug string
Timezone string
SlotMinutes int
DayStartMin int
DayEndMin int
HorizonDays int
Durations string
Weekdays string
CreatedTs int64
}
type Connection struct {
ID int64
UserID int64
Provider string // google | caldav | ics
DisplayName string
ConfigEnc string
LastSyncedTs sql.NullInt64
LastError sql.NullString
CreatedTs int64
}
type BusySlot struct {
ID int64
UserID int64
ConnectionID sql.NullInt64
StartTs int64
EndTs int64
BookingID sql.NullInt64
}
type Booking struct {
ID int64
UserID int64
StartTs int64
EndTs int64
RequesterName string
RequesterEmail string
Message string
Status string // pending | accepted | declined
CreatedTs int64
}
func Open(path string) (*DB, error) {
d, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
// SQLite verträgt parallele Writer nicht gut ein Writer reicht hier.
d.SetMaxOpenConns(1)
if err := d.Ping(); err != nil {
return nil, err
}
dbase := &DB{d}
if err := dbase.migrate(); err != nil {
return nil, err
}
return dbase, nil
}
func (d *DB) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
slot_minutes INTEGER NOT NULL DEFAULT 30,
day_start_min INTEGER NOT NULL DEFAULT 540,
day_end_min INTEGER NOT NULL DEFAULT 1080,
horizon_days INTEGER NOT NULL DEFAULT 21,
durations TEXT NOT NULL DEFAULT '15,30,60,120',
weekdays TEXT NOT NULL DEFAULT '1,2,3,4,5',
created_ts INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS calendar_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
display_name TEXT NOT NULL,
config_enc TEXT NOT NULL,
last_synced_ts INTEGER,
last_error TEXT,
created_ts INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS busy_slots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
connection_id INTEGER REFERENCES calendar_connections(id) ON DELETE CASCADE,
booking_id INTEGER,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_busy_user_time ON busy_slots(user_id, start_ts)`,
`CREATE INDEX IF NOT EXISTS idx_busy_booking ON busy_slots(booking_id)`,
`CREATE TABLE IF NOT EXISTS booking_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
requester_name TEXT NOT NULL,
requester_email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_ts INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_bookings_user ON booking_requests(user_id, created_ts)`,
}
for _, s := range stmts {
if _, err := d.Exec(s); err != nil {
return err
}
}
return nil
}
// --- Users ---
func (d *DB) CreateUser(u *User) error {
return d.QueryRow(
`INSERT INTO users (email, name, password_hash, slug, timezone, created_ts)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
u.Email, u.Name, u.PasswordHash, u.Slug, u.Timezone, time.Now().Unix(),
).Scan(&u.ID)
}
const userCols = `id, email, name, password_hash, slug, timezone, slot_minutes, day_start_min, day_end_min, horizon_days, durations, weekdays, created_ts`
func scanUser(row interface{ Scan(...any) error }) (*User, error) {
u := &User{}
err := row.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Slug, &u.Timezone,
&u.SlotMinutes, &u.DayStartMin, &u.DayEndMin, &u.HorizonDays, &u.Durations, &u.Weekdays, &u.CreatedTs)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return u, nil
}
func (d *DB) UserByEmail(email string) (*User, error) {
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ?`, email))
}
func (d *DB) UserByID(id int64) (*User, error) {
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
}
func (d *DB) UserBySlug(slug string) (*User, error) {
return scanUser(d.QueryRow(`SELECT `+userCols+` FROM users WHERE slug = ?`, slug))
}
func (d *DB) SlugExists(slug string) (bool, error) {
var one int
err := d.QueryRow(`SELECT 1 FROM users WHERE slug = ?`, slug).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func (d *DB) UpdateUserSettings(u *User) error {
_, err := d.Exec(`UPDATE users SET name=?, timezone=?, slot_minutes=?, day_start_min=?, day_end_min=?, horizon_days=?, durations=?, weekdays=? WHERE id=?`,
u.Name, u.Timezone, u.SlotMinutes, u.DayStartMin, u.DayEndMin, u.HorizonDays, u.Durations, u.Weekdays, u.ID)
return err
}
func (d *DB) UpdateUserSlug(id int64, slug string) error {
_, err := d.Exec(`UPDATE users SET slug=? WHERE id=?`, slug, id)
return err
}
// --- Connections ---
func (d *DB) CreateConnection(userID int64, provider, displayName, configEnc string) (int64, error) {
var id int64
err := d.QueryRow(
`INSERT INTO calendar_connections (user_id, provider, display_name, config_enc, created_ts)
VALUES (?, ?, ?, ?, ?) RETURNING id`,
userID, provider, displayName, configEnc, time.Now().Unix(),
).Scan(&id)
return id, err
}
const connCols = `id, user_id, provider, display_name, config_enc, last_synced_ts, last_error, created_ts`
func (d *DB) ConnectionsForUser(userID int64) ([]*Connection, error) {
rows, err := d.Query(`SELECT `+connCols+` FROM calendar_connections WHERE user_id = ? ORDER BY created_ts`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Connection
for rows.Next() {
c := &Connection{}
if err := rows.Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (d *DB) ConnectionByID(id, userID int64) (*Connection, error) {
c := &Connection{}
err := d.QueryRow(`SELECT `+connCols+` FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID).
Scan(&c.ID, &c.UserID, &c.Provider, &c.DisplayName, &c.ConfigEnc, &c.LastSyncedTs, &c.LastError, &c.CreatedTs)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return c, nil
}
func (d *DB) DeleteConnection(id, userID int64) error {
_, err := d.Exec(`DELETE FROM calendar_connections WHERE id = ? AND user_id = ?`, id, userID)
return err
}
func (d *DB) SetConnectionState(id int64, synced bool, errMsg string) error {
var syncedTs any
if synced {
syncedTs = time.Now().Unix()
}
_, err := d.Exec(`UPDATE calendar_connections SET last_synced_ts = ?, last_error = ? WHERE id = ?`, syncedTs, errMsg, id)
return err
}
func (d *DB) UpdateConnectionConfig(id int64, configEnc string) error {
_, err := d.Exec(`UPDATE calendar_connections SET config_enc = ? WHERE id = ?`, configEnc, id)
return err
}
// --- Busy slots ---
func (d *DB) ReplaceBusyForConnection(connID, userID int64, slots []BusySlot) error {
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM busy_slots WHERE connection_id = ?`, connID); err != nil {
return err
}
for _, s := range slots {
if _, err := tx.Exec(`INSERT INTO busy_slots (user_id, connection_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
userID, connID, s.StartTs, s.EndTs); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) BusyForUser(userID, fromTs, toTs int64) ([]BusySlot, error) {
rows, err := d.Query(`SELECT id, user_id, connection_id, start_ts, end_ts, booking_id FROM busy_slots
WHERE user_id = ? AND end_ts > ? AND start_ts < ? ORDER BY start_ts`, userID, fromTs, toTs)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BusySlot
for rows.Next() {
var s BusySlot
if err := rows.Scan(&s.ID, &s.UserID, &s.ConnectionID, &s.StartTs, &s.EndTs, &s.BookingID); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// --- Bookings ---
func (d *DB) CreateBooking(b *Booking) error {
return d.QueryRow(
`INSERT INTO booking_requests (user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) RETURNING id`,
b.UserID, b.StartTs, b.EndTs, b.RequesterName, b.RequesterEmail, b.Message, time.Now().Unix(),
).Scan(&b.ID)
}
const bookingCols = `id, user_id, start_ts, end_ts, requester_name, requester_email, message, status, created_ts`
func (d *DB) BookingsForUser(userID int64) ([]*Booking, error) {
rows, err := d.Query(`SELECT `+bookingCols+` FROM booking_requests WHERE user_id = ? ORDER BY (status='pending') DESC, start_ts ASC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Booking
for rows.Next() {
b := &Booking{}
if err := rows.Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (d *DB) BookingByID(id, userID int64) (*Booking, error) {
b := &Booking{}
err := d.QueryRow(`SELECT `+bookingCols+` FROM booking_requests WHERE id = ? AND user_id = ?`, id, userID).
Scan(&b.ID, &b.UserID, &b.StartTs, &b.EndTs, &b.RequesterName, &b.RequesterEmail, &b.Message, &b.Status, &b.CreatedTs)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return b, nil
}
func (d *DB) SetBookingStatus(id int64, status string) error {
_, err := d.Exec(`UPDATE booking_requests SET status = ? WHERE id = ?`, status, id)
return err
}
func (d *DB) InsertBusyForBooking(b *Booking) error {
_, err := d.Exec(`INSERT INTO busy_slots (user_id, booking_id, start_ts, end_ts) VALUES (?, ?, ?, ?)`,
b.UserID, b.ID, b.StartTs, b.EndTs)
return err
}
func (d *DB) DeleteBusyByBooking(bookingID int64) error {
_, err := d.Exec(`DELETE FROM busy_slots WHERE booking_id = ?`, bookingID)
return err
}