Open() now creates the parent directory of the database path itself, so e.g. DB_PATH=/data/app.db works on first start without a prior mkdir. Open, ping, and migration errors are wrapped with context and hints about writable directories and Docker bind-mount ownership (chown 1000:1000 or use a named volume). Also add a README troubleshooting section for the "database could not be opened" error.
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package db
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"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) {
|
||
// Übergeordnetes Verzeichnis selbst anlegen, damit z. B. DB_PATH=/data/app.db
|
||
// beim ersten Start funktioniert, ohne dass jemand vorab mkdir ausführt.
|
||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("Datenbank-Verzeichnis %s konnte nicht angelegt werden (Berechtigungen prüfen): %w", dir, err)
|
||
}
|
||
}
|
||
d, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Datenbank %s konnte nicht geöffnet werden: %w", path, err)
|
||
}
|
||
// SQLite verträgt parallele Writer nicht gut – ein Writer reicht hier.
|
||
d.SetMaxOpenConns(1)
|
||
if err := d.Ping(); err != nil {
|
||
return nil, fmt.Errorf(
|
||
"Datenbank %s konnte nicht geöffnet oder angelegt werden: %w – "+
|
||
"ist das Verzeichnis beschreibbar? (Docker: Bind-Mounts müssen dem Container-User gehören, z. B. `sudo chown -R 1000:1000 <host-pfad>`; bei Named Volumes passiert das automatisch)",
|
||
path, err)
|
||
}
|
||
dbase := &DB{d}
|
||
if err := dbase.migrate(); err != nil {
|
||
return nil, fmt.Errorf("Datenbank-Schema konnte nicht initialisiert werden: %w", 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
|
||
}
|