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:
Tronax 2026-08-25 18:50:28 +02:00
commit cb30223fd4
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
47 changed files with 6599 additions and 0 deletions

354
backend/internal/db/db.go Normal file
View 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
}