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
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
354
backend/internal/db/db.go
Normal file
354
backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue