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 }