From 5d56ba8112df6aa88c55c1a8ad5f7ccfb659da80 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 11:27:07 -0600 Subject: [PATCH 01/16] Created Turnpike, event attendee and volunteer management Built after prototype, Traverse, an attendee and volunteer list maintainer. --- .envrc | 1 + .gitignore | 8 + Dockerfile | 19 + Makefile | 16 + README.md | 98 ++ auth.go | 123 ++ broker.go | 43 + db.go | 1033 +++++++++++++ docs/INSTALLATION.md | 192 +++ docs/USAGE.md | 183 +++ email.go | 140 ++ flake.lock | 61 + flake.nix | 28 + frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 43 + frontend/index.html | 14 + frontend/jsconfig.json | 33 + frontend/package-lock.json | 1391 ++++++++++++++++++ frontend/package.json | 19 + frontend/public/manifest.json | 13 + frontend/src/App.svelte | 102 ++ frontend/src/api.js | 140 ++ frontend/src/app.css | 177 +++ frontend/src/components/CheckInButton.svelte | 13 + frontend/src/components/Nav.svelte | 57 + frontend/src/components/SyncStatus.svelte | 46 + frontend/src/db.js | 49 + frontend/src/main.js | 9 + frontend/src/pages/Attendees.svelte | 274 ++++ frontend/src/pages/Dashboard.svelte | 62 + frontend/src/pages/Departments.svelte | 190 +++ frontend/src/pages/GateUI.svelte | 451 ++++++ frontend/src/pages/Import.svelte | 96 ++ frontend/src/pages/Kiosk.svelte | 356 +++++ frontend/src/pages/Login.svelte | 49 + frontend/src/pages/ScheduleBoard.svelte | 452 ++++++ frontend/src/pages/Settings.svelte | 151 ++ frontend/src/pages/Shifts.svelte | 221 +++ frontend/src/pages/Users.svelte | 266 ++++ frontend/src/pages/Volunteers.svelte | 241 +++ frontend/src/sync.js | 115 ++ frontend/svelte.config.js | 8 + frontend/vite.config.js | 15 + go.mod | 22 + go.sum | 57 + handle_attendees.go | 167 +++ handle_auth.go | 49 + handle_departments.go | 75 + handle_event.go | 37 + handle_import.go | 158 ++ handle_kiosk.go | 132 ++ handle_settings.go | 78 + handle_shifts.go | 179 +++ handle_sync.go | 86 ++ handle_tokens.go | 98 ++ handle_users.go | 105 ++ handle_volunteers.go | 181 +++ main.go | 214 +++ 59 files changed, 8663 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 auth.go create mode 100644 broker.go create mode 100644 db.go create mode 100644 docs/INSTALLATION.md create mode 100644 docs/USAGE.md create mode 100644 email.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/jsconfig.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/manifest.json create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/api.js create mode 100644 frontend/src/app.css create mode 100644 frontend/src/components/CheckInButton.svelte create mode 100644 frontend/src/components/Nav.svelte create mode 100644 frontend/src/components/SyncStatus.svelte create mode 100644 frontend/src/db.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/pages/Attendees.svelte create mode 100644 frontend/src/pages/Dashboard.svelte create mode 100644 frontend/src/pages/Departments.svelte create mode 100644 frontend/src/pages/GateUI.svelte create mode 100644 frontend/src/pages/Import.svelte create mode 100644 frontend/src/pages/Kiosk.svelte create mode 100644 frontend/src/pages/Login.svelte create mode 100644 frontend/src/pages/ScheduleBoard.svelte create mode 100644 frontend/src/pages/Settings.svelte create mode 100644 frontend/src/pages/Shifts.svelte create mode 100644 frontend/src/pages/Users.svelte create mode 100644 frontend/src/pages/Volunteers.svelte create mode 100644 frontend/src/sync.js create mode 100644 frontend/svelte.config.js create mode 100644 frontend/vite.config.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handle_attendees.go create mode 100644 handle_auth.go create mode 100644 handle_departments.go create mode 100644 handle_event.go create mode 100644 handle_import.go create mode 100644 handle_kiosk.go create mode 100644 handle_settings.go create mode 100644 handle_shifts.go create mode 100644 handle_sync.go create mode 100644 handle_tokens.go create mode 100644 handle_users.go create mode 100644 handle_volunteers.go create mode 100644 main.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d59524b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +turnpike +*.db +*.db-shm +*.db-wal +vendor/ +frontend/dist/ +frontend/node_modules/ +.direnv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fe5f00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM golang:1.24-alpine AS backend +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY --from=frontend /app/frontend/dist ./frontend/dist +COPY *.go ./ +RUN CGO_ENABLED=0 go build -o turnpike . + +FROM scratch +COPY --from=backend /app/turnpike /turnpike +EXPOSE 8180 +ENTRYPOINT ["/turnpike"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..72a39be --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: build frontend-build dev clean + +build: frontend-build + CGO_ENABLED=0 go build -o turnpike . + +frontend-build: + cd frontend && npm ci && npm run build + +dev: + @echo "Run in two terminals:" + @echo " Terminal 1: go run . --db dev.db" + @echo " Terminal 2: cd frontend && npm run dev" + +clean: + rm -f turnpike dev.db + rm -rf frontend/dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..b526fbe --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Turnpike + +Self-hosted event attendee and volunteer management. One instance, one event. + +Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns. + +## Features + +- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in +- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering +- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in +- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness +- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate +- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync +- **Real-time** — check-ins and changes broadcast live via SSE +- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms +- **Single binary** — Go backend embeds the frontend; no runtime dependencies + +## Tech Stack + +- **Backend:** Go, SQLite (WAL mode), JWT auth +- **Frontend:** Svelte 5, Dexie (IndexedDB), Vite +- **Deployment:** single static binary — no CGO, no external database + +## Quick Start + +```sh +# Build (frontend + Go binary) +make build + +# Run (creates admin user on first startup) +TURNPIKE_ADMIN_USER=admin TURNPIKE_ADMIN_PASSWORD=changeme ./turnpike +``` + +Open `http://localhost:8180`. + +See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and reverse proxy setup. + +## Configuration + +| Flag | Env var | Default | Description | +|------|---------|---------|-------------| +| `--addr` | — | `0.0.0.0:8180` | Listen address | +| `--db` | — | `turnpike.db` | SQLite database path | +| `--secret` | `TURNPIKE_SECRET` | auto-generated | JWT signing secret (persist across restarts) | +| `--token-expiry` | — | `24` | JWT lifetime in hours | +| `--base-url` | — | — | Public URL for volunteer token links | +| `--smtp-host` | — | — | SMTP server hostname | +| `--smtp-port` | — | `587` | SMTP port (587 = STARTTLS, 465 = implicit TLS) | +| `--smtp-user` | — | — | SMTP username | +| `--smtp-password` | — | — | SMTP password | +| `--smtp-from` | — | — | Sender email address | +| `--smtp-from-name` | — | — | Sender display name | +| — | `TURNPIKE_ADMIN_USER` | — | Bootstrap admin username (first run only) | +| — | `TURNPIKE_ADMIN_PASSWORD` | — | Bootstrap admin password (first run only) | + +## User Roles + +| Role | Access | +|------|--------| +| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | +| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings | +| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | +| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | + +See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. + +## Development + +**Prerequisites:** [Nix](https://nixos.org) + [direnv](https://direnv.net), or Go 1.24+ and Node.js 18+ installed manually. + +```sh +git clone +cd turnpike +direnv allow # activates Go + Node.js via flake.nix +``` + +**Two-terminal dev setup:** + +```sh +# Terminal 1 — Go API server +go run . --db dev.db + +# Terminal 2 — Vite dev server (proxies /api to :8180) +cd frontend && npm install && npm run dev +``` + +The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server on `:8180`. + +## Documentation + +- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board +- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup + +## License + +CC BY-NC-SA 4.0 diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..c2d11af --- /dev/null +++ b/auth.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type Claims struct { + UserID int `json:"uid"` + Username string `json:"sub"` + Role string `json:"role"` + DeptIDs []int `json:"dept_ids,omitempty"` + jwt.RegisteredClaims +} + +func hashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +func checkPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func (app *App) signToken(u *User) (string, error) { + expiry := time.Duration(app.tokenExpiry) * time.Hour + claims := Claims{ + UserID: u.ID, + Username: u.Username, + Role: u.Role, + DeptIDs: u.DepartmentIDs, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(app.secret)) +} + +func (app *App) parseToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(app.secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + return claims, nil +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + // Fallback to query param for SSE (EventSource can't set headers) + return r.URL.Query().Get("token") +} + +// requireAuth wraps a handler, injects claims into context via X-Claims header trick. +// We pass claims via a request-scoped value instead. +type contextKey string + +const claimsKey contextKey = "claims" + +func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r) + if token == "" { + writeError(w, "unauthorized", http.StatusUnauthorized) + return + } + claims, err := app.parseToken(token) + if err != nil { + writeError(w, "unauthorized", http.StatusUnauthorized) + return + } + if len(roles) > 0 && !hasRole(claims.Role, roles) { + writeError(w, "forbidden", http.StatusForbidden) + return + } + ctx := context.WithValue(r.Context(), claimsKey, claims) + next(w, r.WithContext(ctx)) + } +} + +func hasRole(role string, allowed []string) bool { + for _, r := range allowed { + if r == role { + return true + } + } + return false +} + +func claimsFromContext(r *http.Request) *Claims { + c, _ := r.Context().Value(claimsKey).(*Claims) + return c +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/broker.go b/broker.go new file mode 100644 index 0000000..a68b689 --- /dev/null +++ b/broker.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "sync" +) + +// Broker is a simple in-memory pub/sub for SSE events. +type Broker struct { + mu sync.Mutex + clients map[chan []byte]struct{} +} + +func newBroker() *Broker { + return &Broker{clients: make(map[chan []byte]struct{})} +} + +func (b *Broker) subscribe() chan []byte { + ch := make(chan []byte, 8) + b.mu.Lock() + b.clients[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +func (b *Broker) unsubscribe(ch chan []byte) { + b.mu.Lock() + delete(b.clients, ch) + b.mu.Unlock() + close(ch) +} + +func (b *Broker) publish(event string, data any) { + payload, _ := json.Marshal(map[string]any{"event": event, "data": data}) + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.clients { + select { + case ch <- payload: + default: + } + } +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..2d7b8d2 --- /dev/null +++ b/db.go @@ -0,0 +1,1033 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +func initDB(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + db.SetMaxOpenConns(1) + db.Exec("PRAGMA journal_mode=WAL") + db.Exec("PRAGMA foreign_keys=ON") + db.Exec("PRAGMA busy_timeout=5000") + + if err := migrate(db); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return db, nil +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS event ( + id INTEGER PRIMARY KEY CHECK(id = 1), + name TEXT NOT NULL, + venue TEXT NOT NULL DEFAULT '', + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + timezone TEXT NOT NULL DEFAULT 'America/Chicago', + description TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin','coordinator','gate','ticketing','volunteer_lead')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS user_departments ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, department_id) + ); + + CREATE TABLE IF NOT EXISTS departments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#6366f1', + description TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE TABLE IF NOT EXISTS attendees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + ticket_id TEXT NOT NULL DEFAULT '', + ticket_type TEXT NOT NULL DEFAULT '', + volunteer_token TEXT UNIQUE, + party_size INTEGER NOT NULL DEFAULT 1, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_count INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + checked_in_by INTEGER REFERENCES users(id), + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket + ON attendees(name, ticket_id) WHERE deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS volunteers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE TABLE IF NOT EXISTS shifts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + name TEXT NOT NULL, + day TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + capacity INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); + + CREATE TABLE IF NOT EXISTS volunteer_shifts ( + volunteer_id INTEGER NOT NULL REFERENCES volunteers(id) ON DELETE CASCADE, + shift_id INTEGER NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + confirmed INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (volunteer_id, shift_id) + ); + `) + if err != nil { + return err + } + return migrateV2(db) +} + +// migrateV2 adds new columns to existing databases without data loss. +func migrateV2(db *sql.DB) error { + addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") + addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") + addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + // Widen the uniqueness constraint from name-only to (name, ticket_id). + db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) + return nil +} + +func addColumnIfMissing(db *sql.DB, table, colDef string) { + colName := strings.Fields(colDef)[0] + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == colName { + return + } + } + db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) +} + +// --- Types --- + +const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, + party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, + note, created_at, updated_at, deleted_at` + +const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` +const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` + +type Event struct { + ID int `json:"id"` + Name string `json:"name"` + Venue string `json:"venue"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Timezone string `json:"timezone"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` +} + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` +} + +type Attendee struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + TicketID string `json:"ticket_id"` + TicketType string `json:"ticket_type"` + VolunteerToken *string `json:"volunteer_token,omitempty"` + PartySize int `json:"party_size"` + CheckedIn bool `json:"checked_in"` + CheckedInCount int `json:"checked_in_count"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type Department struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type Volunteer struct { + ID int `json:"id"` + AttendeeID *int `json:"attendee_id,omitempty"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + CheckedIn bool `json:"checked_in"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type Shift struct { + ID int `json:"id"` + DepartmentID int `json:"department_id"` + Name string `json:"name"` + Day string `json:"day"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Capacity int `json:"capacity"` + Position int `json:"position"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +type VolunteerShift struct { + VolunteerID int `json:"volunteer_id"` + ShiftID int `json:"shift_id"` + Confirmed bool `json:"confirmed"` + UpdatedAt string `json:"updated_at"` +} + +// --- Event --- + +func (app *App) getEvent() (*Event, error) { + var e Event + err := app.db.QueryRow( + `SELECT id, name, venue, start_date, end_date, timezone, description, updated_at FROM event WHERE id = 1`, + ).Scan(&e.ID, &e.Name, &e.Venue, &e.StartDate, &e.EndDate, &e.Timezone, &e.Description, &e.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return &e, err +} + +func (app *App) upsertEvent(e Event) error { + _, err := app.db.Exec(` + INSERT INTO event (id, name, venue, start_date, end_date, timezone, description, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, venue=excluded.venue, + start_date=excluded.start_date, end_date=excluded.end_date, + timezone=excluded.timezone, description=excluded.description, + updated_at=excluded.updated_at + `, e.Name, e.Venue, e.StartDate, e.EndDate, e.Timezone, e.Description, now()) + return err +} + +// --- Users --- + +func (app *App) getUserDeptIDs(userID int) ([]int, error) { + rows, err := app.db.Query( + `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int + for rows.Next() { + var id int + rows.Scan(&id) + ids = append(ids, id) + } + if ids == nil { + ids = []int{} + } + return ids, rows.Err() +} + +func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { + _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) + if err != nil { + return err + } + for _, deptID := range deptIDs { + if _, err := app.db.Exec( + `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, + ); err != nil { + return err + } + } + return nil +} + +func (app *App) getUserByUsername(username string) (*User, string, error) { + var u User + var hash string + err := app.db.QueryRow( + `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, hash, err +} + +func (app *App) getUserByID(id int) (*User, error) { + var u User + err := app.db.QueryRow( + `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, err +} + +func (app *App) listUsers() ([]User, error) { + rows, err := app.db.Query( + `SELECT id, username, role, created_at FROM users ORDER BY username`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + u.DepartmentIDs = []int{} + users = append(users, u) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range users { + users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) + } + return users, nil +} + +func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { + res, err := app.db.Exec( + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, + username, hash, role, + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { + return nil, err + } + return app.getUserByID(int(id)) +} + +func (app *App) updateUser(id int, role string, deptIDs []int) error { + if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil { + return err + } + return app.setUserDeptIDs(id, deptIDs) +} + +func (app *App) updateUserPassword(id int, hash string) error { + _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + return err +} + +func (app *App) deleteUser(id int) error { + _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id) + return err +} + +func (app *App) countUsers() (int, error) { + var n int + err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +// --- Tokens --- + +const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +func generateToken() string { + b := make([]byte, 8) + rand.Read(b) + result := make([]byte, 8) + for i, v := range b { + result[i] = tokenChars[int(v)%len(tokenChars)] + } + return string(result) +} + +func (app *App) generateUniqueToken() (string, error) { + for range 10 { + t := generateToken() + var count int + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count) + if count == 0 { + return t, nil + } + } + return "", fmt.Errorf("failed to generate unique token") +} + +func (app *App) getAttendeeByToken(token string) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +// generateTokensForAll creates tokens for every attendee that doesn't have one yet. +func (app *App) generateTokensForAll() (int, error) { + rows, err := app.db.Query( + `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`, + ) + if err != nil { + return 0, err + } + var ids []int + for rows.Next() { + var id int + rows.Scan(&id) + ids = append(ids, id) + } + rows.Close() + + count := 0 + for _, id := range ids { + t, err := app.generateUniqueToken() + if err != nil { + continue + } + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) + count++ + } + return count, nil +} + +// incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id. +// Used during import to handle duplicate ticket rows from the same order. +func (app *App) incrementPartySize(name, ticketID string) (bool, error) { + res, err := app.db.Exec( + `UPDATE attendees SET party_size = party_size + 1, updated_at = ? + WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, + now(), name, ticketID, + ) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// --- Attendees --- + +func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { + q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` + var args []any + if search != "" { + q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s, s) + } + if ticketType != "" { + q += ` AND ticket_type = ?` + args = append(args, ticketType) + } + if checkedIn == "true" { + q += ` AND checked_in = 1` + } else if checkedIn == "false" { + q += ` AND checked_in = 0` + } + q += ` ORDER BY name ASC` + return queryAttendees(app.db, q, args...) +} + +func (app *App) getAttendee(id int) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createAttendee(a Attendee) (*Attendee, error) { + res, err := app.db.Exec( + `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getAttendee(int(id)) +} + +func (app *App) updateAttendee(a Attendee) error { + _, err := app.db.Exec( + `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? + WHERE id = ? AND deleted_at IS NULL`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, + ) + return err +} + +func (app *App) deleteAttendee(id int) error { + _, err := app.db.Exec( + `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, + ) + return err +} + +// checkInAttendee increments checked_in_count by count (capped at party_size). +// Sets checked_in and checked_in_at on the first check-in. +func (app *App) checkInAttendee(id, userID, count int) (*Attendee, error) { + if count < 1 { + count = 1 + } + a, err := app.getAttendee(id) + if err != nil || a == nil { + return nil, err + } + remaining := a.PartySize - a.CheckedInCount + if count > remaining { + count = remaining + } + if count <= 0 { + return a, nil + } + t := now() + _, err = app.db.Exec(` + UPDATE attendees SET + checked_in_count = checked_in_count + ?, + checked_in = CASE WHEN checked_in = 0 THEN 1 ELSE checked_in END, + checked_in_at = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_at END, + checked_in_by = CASE WHEN checked_in = 0 THEN ? ELSE checked_in_by END, + updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + count, t, userID, t, id, + ) + if err != nil { + return nil, err + } + return app.getAttendee(id) +} + +func (app *App) attendeesSince(since string) ([]Attendee, error) { + return queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE updated_at > ? ORDER BY updated_at ASC`, since) +} + +func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Attendee + for rows.Next() { + var a Attendee + var checkedIn int + var token sql.NullString + if err := rows.Scan( + &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, + &token, &a.PartySize, &checkedIn, &a.CheckedInCount, + &a.CheckedInAt, &a.CheckedInBy, &a.Note, + &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, + ); err != nil { + return nil, err + } + if token.Valid && token.String != "" { + a.VolunteerToken = &token.String + } + a.CheckedIn = checkedIn == 1 + if a.PartySize < 1 { + a.PartySize = 1 + } + result = append(result, a) + } + return result, rows.Err() +} + +func (app *App) attendeeTicketTypes() ([]string, error) { + rows, err := app.db.Query( + `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var types []string + for rows.Next() { + var t string + rows.Scan(&t) + types = append(types, t) + } + return types, rows.Err() +} + +func (app *App) attendeeCounts() (total, checkedIn int, err error) { + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) + return +} + +// --- Departments --- + +func (app *App) listDepartments(since string) ([]Department, error) { + var q string + var args []any + if since != "" { + q = `SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE updated_at > ? ORDER BY name` + args = append(args, since) + } else { + q = `SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE deleted_at IS NULL ORDER BY name` + } + return queryDepartments(app.db, q, args...) +} + +func (app *App) getDepartment(id int) (*Department, error) { + rows, err := queryDepartments(app.db, + `SELECT id, name, color, description, updated_at, deleted_at FROM departments WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createDepartment(d Department) (*Department, error) { + res, err := app.db.Exec( + `INSERT INTO departments (name, color, description, updated_at) VALUES (?, ?, ?, ?)`, + d.Name, d.Color, d.Description, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getDepartment(int(id)) +} + +func (app *App) updateDepartment(d Department) error { + _, err := app.db.Exec( + `UPDATE departments SET name=?, color=?, description=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + d.Name, d.Color, d.Description, now(), d.ID, + ) + return err +} + +func (app *App) deleteDepartment(id int) error { + _, err := app.db.Exec( + `UPDATE departments SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + ) + return err +} + +func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Department + for rows.Next() { + var d Department + rows.Scan(&d.ID, &d.Name, &d.Color, &d.Description, &d.UpdatedAt, &d.DeletedAt) + result = append(result, d) + } + return result, rows.Err() +} + +// --- Volunteers --- + +const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` + +func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { + q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` + var args []any + if since != "" { + q += ` AND updated_at > ?` + args = append(args, since) + } else { + q += ` AND deleted_at IS NULL` + } + if search != "" { + q += ` AND (name LIKE ? OR email LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s) + } + if deptID != nil { + q += ` AND department_id = ?` + args = append(args, *deptID) + } + q += ` ORDER BY name` + return queryVolunteers(app.db, q, args...) +} + +func (app *App) getVolunteer(id int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { + res, err := app.db.Exec( + `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getVolunteer(int(id)) +} + +func (app *App) updateVolunteer(v Volunteer) error { + _, err := app.db.Exec( + `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? + WHERE id=? AND deleted_at IS NULL`, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, + ) + return err +} + +func (app *App) deleteVolunteer(id int) error { + _, err := app.db.Exec( + `UPDATE volunteers SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + ) + return err +} + +// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, +// also increments the attendee's checked_in_count. +func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { + t := now() + _, err := app.db.Exec( + `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND checked_in=0`, + t, t, id, + ) + if err != nil { + return nil, err + } + v, err := app.getVolunteer(id) + if err != nil || v == nil { + return v, err + } + if v.AttendeeID != nil { + app.checkInAttendee(*v.AttendeeID, userID, 1) + } + return v, nil +} + +func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Volunteer + for rows.Next() { + var v Volunteer + var attendeeID, deptID sql.NullInt64 + var isLead, checkedIn int + if err := rows.Scan( + &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, + &isLead, &checkedIn, &v.CheckedInAt, &v.Note, + &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, + ); err != nil { + return nil, err + } + if attendeeID.Valid { + id := int(attendeeID.Int64) + v.AttendeeID = &id + } + if deptID.Valid { + id := int(deptID.Int64) + v.DepartmentID = &id + } + v.IsLead = isLead == 1 + v.CheckedIn = checkedIn == 1 + result = append(result, v) + } + return result, rows.Err() +} + +// --- Shifts --- + +func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { + q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` + var args []any + if since != "" { + q += ` AND updated_at > ?` + args = append(args, since) + } else { + q += ` AND deleted_at IS NULL` + } + if deptID != nil { + q += ` AND department_id = ?` + args = append(args, *deptID) + } + if day != "" { + q += ` AND day = ?` + args = append(args, day) + } + q += ` ORDER BY day, position, start_time` + return queryShifts(app.db, q, args...) +} + +func (app *App) getShift(id int) (*Shift, error) { + rows, err := queryShifts(app.db, + `SELECT `+shiftCols+` FROM shifts WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createShift(s Shift) (*Shift, error) { + res, err := app.db.Exec( + `INSERT INTO shifts (department_id, name, day, start_time, end_time, capacity, position, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + s.DepartmentID, s.Name, s.Day, s.StartTime, s.EndTime, s.Capacity, s.Position, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getShift(int(id)) +} + +func (app *App) updateShift(s Shift) error { + _, err := app.db.Exec( + `UPDATE shifts SET department_id=?, name=?, day=?, start_time=?, end_time=?, capacity=?, position=?, updated_at=? + WHERE id=? AND deleted_at IS NULL`, + s.DepartmentID, s.Name, s.Day, s.StartTime, s.EndTime, s.Capacity, s.Position, now(), s.ID, + ) + return err +} + +func (app *App) deleteShift(id int) error { + _, err := app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id) + return err +} + +func queryShifts(db *sql.DB, q string, args ...any) ([]Shift, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Shift + for rows.Next() { + var s Shift + rows.Scan(&s.ID, &s.DepartmentID, &s.Name, &s.Day, &s.StartTime, &s.EndTime, + &s.Capacity, &s.Position, &s.UpdatedAt, &s.DeletedAt) + result = append(result, s) + } + return result, rows.Err() +} + +// shiftAssignedCount returns the number of volunteers currently assigned to a shift. +func (app *App) shiftAssignedCount(shiftID int) (int, error) { + var count int + err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ?`, shiftID).Scan(&count) + return count, err +} + +// checkShiftConflict returns any of the volunteer's existing shifts that overlap +// on the same day as the target shift. +func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) { + target, err := app.getShift(shiftID) + if err != nil || target == nil { + return nil, err + } + existing, err := queryShifts(app.db, ` + SELECT `+shiftColsS+` + FROM shifts s + JOIN volunteer_shifts vs ON vs.shift_id = s.id + WHERE vs.volunteer_id = ? AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, + volunteerID, target.Day, shiftID) + if err != nil { + return nil, err + } + var conflicts []Shift + for _, s := range existing { + // Overlap: one starts before the other ends (HH:MM string comparison works for same-day) + if s.StartTime < target.EndTime && target.StartTime < s.EndTime { + conflicts = append(conflicts, s) + } + } + return conflicts, nil +} + +// reorderShifts updates the position field for each given shift. +func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { + for _, p := range positions { + if _, err := app.db.Exec( + `UPDATE shifts SET position=?, updated_at=? WHERE id=?`, p.Position, now(), p.ID, + ); err != nil { + return err + } + } + return nil +} + +// --- Volunteer Shifts --- + +func (app *App) assignShift(volunteerID, shiftID int) error { + _, err := app.db.Exec( + `INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, updated_at=excluded.updated_at`, + volunteerID, shiftID, now(), + ) + return err +} + +func (app *App) unassignShift(volunteerID, shiftID int) error { + _, err := app.db.Exec( + `DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, volunteerID, shiftID, + ) + return err +} + +func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) { + var q string + var args []any + if since != "" { + q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts WHERE updated_at > ?` + args = append(args, since) + } else { + q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts` + } + rows, err := app.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []VolunteerShift + for rows.Next() { + var vs VolunteerShift + var confirmed int + rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt) + vs.Confirmed = confirmed == 1 + result = append(result, vs) + } + return result, rows.Err() +} + +// listShiftsForVolunteer returns all shifts the volunteer is assigned to. +func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) { + return queryShifts(app.db, ` + SELECT `+shiftColsS+` + FROM shifts s + JOIN volunteer_shifts vs ON vs.shift_id = s.id + WHERE vs.volunteer_id = ? AND s.deleted_at IS NULL + ORDER BY s.day, s.position, s.start_time`, volunteerID) +} + +// listOpenShiftsForDept returns shifts in a department that still have capacity. +func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { + return queryShifts(app.db, ` + SELECT `+shiftCols+` + FROM shifts s + WHERE s.department_id = ? AND s.deleted_at IS NULL + AND (s.capacity = 0 OR ( + SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id + ) < s.capacity) + ORDER BY s.day, s.position, s.start_time`, deptID) +} + +// --- Helpers --- + +func now() string { + return time.Now().UTC().Format("2006-01-02T15:04:05Z") +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..1f9a967 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,192 @@ +# Turnpike Installation Guide + +This guide covers building, deploying, and operating Turnpike. For usage and event workflows, see [USAGE.md](USAGE.md). + +## Requirements + +- **Go 1.24+** (for building) +- **Node.js 18+** (for building the frontend) +- No external database — Turnpike uses embedded SQLite via a pure-Go driver + +## Building from Source + +```sh +git clone +cd turnpike +make build +``` + +This runs `npm ci && npm run build` in `frontend/`, then `CGO_ENABLED=0 go build -o turnpike .`. The result is a single static binary with the frontend assets embedded. No runtime dependencies. + +Other make targets: `make dev` (prints two-terminal dev instructions), `make clean` (removes binary and build artifacts). + +## Configuration Reference + +| Flag | Env var | Default | Description | +|------|---------|---------|-------------| +| `--addr` | — | `0.0.0.0:8180` | Listen address | +| `--db` | — | `turnpike.db` | SQLite database path | +| `--secret` | `TURNPIKE_SECRET` | auto-generated | JWT signing secret | +| `--token-expiry` | — | `24` | JWT lifetime in hours | +| `--base-url` | — | — | Public URL for volunteer token links | +| `--smtp-host` | — | — | SMTP server hostname | +| `--smtp-port` | — | `587` | SMTP port (587 = STARTTLS, 465 = implicit TLS) | +| `--smtp-user` | — | — | SMTP username | +| `--smtp-password` | — | — | SMTP password | +| `--smtp-from` | — | — | Sender email address | +| `--smtp-from-name` | — | — | Sender display name | +| — | `TURNPIKE_ADMIN_USER` | — | Bootstrap admin username (first run only) | +| — | `TURNPIKE_ADMIN_PASSWORD` | — | Bootstrap admin password (first run only) | + +SMTP settings can also be configured at runtime through the Settings page (admin only). CLI flags override stored database values. + +## Running + +```sh +# Minimal startup (creates admin user on first run) +TURNPIKE_ADMIN_USER=admin TURNPIKE_ADMIN_PASSWORD=changeme ./turnpike + +# With explicit options +./turnpike \ + --addr 0.0.0.0:8180 \ + --db /var/lib/turnpike/turnpike.db \ + --secret your-jwt-secret \ + --base-url https://turnpike.example.com +``` + +The admin account is only created on first startup when no users exist and both `TURNPIKE_ADMIN_USER` and `TURNPIKE_ADMIN_PASSWORD` are set. After the first user is created, these environment variables have no effect. + +### JWT Secret + +If `--secret` / `TURNPIKE_SECRET` is not provided, Turnpike auto-generates a secret and stores it in the database. This persists across restarts as long as the database file is preserved. To use an explicit secret (recommended for production), pass it via flag or env var. + +Changing or losing the JWT secret invalidates all active sessions — users will need to log in again. + +## Systemd + +```ini +[Unit] +Description=Turnpike event management +After=network.target + +[Service] +ExecStart=/usr/local/bin/turnpike --db /var/lib/turnpike/turnpike.db --base-url https://turnpike.example.com +Environment=TURNPIKE_SECRET=your-secret-here +EnvironmentFile=/etc/turnpike/admin.env +StateDirectory=turnpike +DynamicUser=true +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +The `admin.env` file should contain: +``` +TURNPIKE_ADMIN_USER=admin +TURNPIKE_ADMIN_PASSWORD=your-password +``` + +Note: systemd uses `EnvironmentFile` (singular). The plural `EnvironmentFiles` is silently ignored. + +## Docker + +A multi-stage `Dockerfile` is included in the repo (node build, Go build, scratch final image): + +```sh +docker build -t turnpike . +docker run -p 8180:8180 \ + -v turnpike-data:/data \ + -e TURNPIKE_ADMIN_USER=admin \ + -e TURNPIKE_ADMIN_PASSWORD=changeme \ + -e TURNPIKE_SECRET=your-secret \ + turnpike --db /data/turnpike.db +``` + +## NixOS + +Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): + +```nix +turnpike = pkgs.buildGoModule { + pname = "turnpike"; + version = "0.1.0"; + src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ + vendorHash = null; + env.CGO_ENABLED = 0; +}; +``` + +The source directory must contain: +- Go source files and `vendor/` (run `go mod vendor`) +- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`) + +A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`. + +## Reverse Proxy + +Turnpike serves both the API and frontend on a single port. Behind a reverse proxy, ensure SSE connections for real-time sync are not buffered. + +### nginx + +```nginx +server { + listen 443 ssl; + server_name turnpike.example.com; + + location / { + proxy_pass http://127.0.0.1:8180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/sync/stream { + proxy_pass http://127.0.0.1:8180; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } +} +``` + +The `/api/sync/stream` endpoint uses Server-Sent Events (SSE). The key settings are `proxy_buffering off` and a long `proxy_read_timeout` to keep the connection alive. + +## TLS / HTTPS + +Use a TLS termination proxy (nginx, Caddy, Traefik) with Let's Encrypt or your own certificates. Turnpike itself serves plain HTTP. + +Set `--base-url https://turnpike.example.com` so that volunteer token links in exported CSVs and emails use the correct URL. + +## Backup + +Turnpike stores all data in a single SQLite file (the path passed to `--db`). + +**Simple backup:** copy the `.db` file while the server is running — SQLite WAL mode ensures a consistent snapshot. + +**Continuous replication:** [Litestream](https://litestream.io) streams WAL changes to S3, GCS, or Azure Blob Storage with near-zero overhead. + +## Upgrading + +1. Pull the latest source +2. Rebuild: `make build` +3. Replace the binary and restart the service + +Database migrations run automatically on startup. All migrations are additive (`ALTER TABLE ... ADD COLUMN`) — no data is lost. + +## Troubleshooting + +**Login fails after restart:** If the JWT secret wasn't persisted (`--secret` or `TURNPIKE_SECRET`), a new secret is generated on restart, invalidating all existing tokens. Users need to log in again. To prevent this, always set an explicit secret in production. + +**Admin account not created:** The bootstrap admin is only created on first startup when no users exist. If the service started without `TURNPIKE_ADMIN_USER` / `TURNPIKE_ADMIN_PASSWORD`, users were never created. Delete the database file and restart with the env vars set. + +**Soft deletes:** Records are marked with `deleted_at`, not removed from the database. This is required for sync — clients need to see deletions to purge their local IndexedDB. Don't hard-delete records directly from the SQLite database. + +**`EnvironmentFile` vs `EnvironmentFiles`:** systemd's directive is `EnvironmentFile` (singular). Using the plural form silently ignores the file — the service starts but environment variables are never set. + +**SSE disconnections:** If real-time updates stop working behind a proxy, check that `proxy_buffering off` is set for `/api/sync/stream`. The client reconnects automatically, but buffering proxies can prevent events from reaching the browser. diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..2d78bff --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,183 @@ +# Turnpike Usage Guide + +This guide is for event organizers and ops teams running a Turnpike instance. For installation and deployment, see [INSTALLATION.md](INSTALLATION.md). + +## First Login + +On first startup with `TURNPIKE_ADMIN_USER` and `TURNPIKE_ADMIN_PASSWORD` set, Turnpike creates a bootstrap admin account. Log in at `https://your-instance/` with those credentials. + +After logging in, create accounts for your team under **Users**. Each user gets a username, password, and role. The admin bootstrap credentials are only used on initial setup — they have no effect on subsequent restarts. + +## User Roles + +| Role | What they see | What they can do | +|------|--------------|------------------| +| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers | +| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | +| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | +| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | + +Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions. + +Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department. + +## Event Setup + +1. **Configure your event** — go to the Dashboard and set the event name and dates. +2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). +3. **Import attendees** — see next section. +4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity. + +## Importing Attendees + +Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: + +### CrowdWork / Zeffy format + +| Column | Maps to | +|--------|---------| +| `Patron Name` | Name | +| `Patron Email` | Email | +| `Order Number` | Ticket ID | +| `Tier Name` | Ticket type | + +### Generic format + +| Column | Maps to | +|--------|---------| +| `name` (required) | Name | +| `email` | Email | +| `ticket_id` | Ticket ID | +| `ticket_type` | Ticket type | +| `note` | Note | + +Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically. + +### Party-size dedup + +CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: + +- First row for "Alice Smith" (order 1234) creates a record with `party_size=1` +- Subsequent rows with the same name + order number increment `party_size` (no duplicate record) +- Result: one attendee record, `party_size=3` if three tickets were purchased + +The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). + +Re-importing the same CSV is safe — existing records are skipped, not duplicated. + +## Managing Volunteers + +Under **Volunteers**, you can: + +- Create volunteers manually (name, email, department) +- Link a volunteer to an existing attendee record (for dual check-in at the gate) +- Assign volunteers to departments +- Check in volunteers + +Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously. + +## Shift Scheduling + +Under **Shifts**, create shifts for each department: + +- **Day** — the date of the shift +- **Start/end time** — HH:MM format +- **Capacity** — maximum number of volunteers + +### Assigning volunteers + +From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. + +### Reordering + +Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering. + +## Volunteer Kiosk + +The kiosk lets volunteers self-select shifts without logging in. + +### Setup + +1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one. +2. **Distribute tokens** — two options: + - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform. + - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. +3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. + +### Volunteer experience + +Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing: + +- Their name and department +- Currently assigned shifts +- Available shifts with remaining capacity + +Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway. + +No login is required. The 8-character token authenticates the request. + +### Token format + +Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). + +## Gate Check-In + +Users with the **gate** role see a dedicated full-screen UI: + +- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. +- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline). +- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining." +- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously. +- **Recent check-ins** — the last 10 check-ins are shown for quick reference. + +Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available. + +## Schedule Board + +The Schedule Board is the primary UI for coordinators and volunteer leads. It shows: + +- Shifts grouped by department and day +- Each shift card shows: name, time, capacity (used/total), assigned volunteers +- Conflict badges when a volunteer has overlapping shifts on the same day + +**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. + +Actions available: +- Assign volunteers to shifts from a dropdown +- Remove volunteer assignments +- Reorder shifts within a department +- Edit shift details inline + +## SMTP Configuration + +SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): + +| Field | Description | +|-------|-------------| +| SMTP Host | Mail server hostname (e.g., `smtp.fastmail.com`) | +| SMTP Port | `587` for STARTTLS (default), `465` for implicit TLS | +| SMTP User | Login username | +| SMTP Password | Login password | +| From Address | Sender email address | +| From Name | Sender display name | + +After saving, use "Send Test Email" to verify the configuration. + +SMTP can also be set via CLI flags (`--smtp-host`, etc.) which override database values. + +## Offline Mode + +Turnpike is a Progressive Web App (PWA). After the first load, it works offline: + +- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns. +- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. + +Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. + +## CSV Exports + +Two CSV exports are available from the Attendees page: + +- **Attendee export** — all attendee records with check-in status +- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns. diff --git a/email.go b/email.go new file mode 100644 index 0000000..05c94f0 --- /dev/null +++ b/email.go @@ -0,0 +1,140 @@ +package main + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strings" +) + +type SMTPConfig struct { + Host string + Port int + User string + Password string + From string + FromName string +} + +// loadSMTPConfig reads SMTP settings from the config table, overlaying any +// values set via CLI flags (which take priority). +func (app *App) loadSMTPConfig() SMTPConfig { + get := func(key string) string { + var v string + app.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v) + return v + } + + cfg := SMTPConfig{ + Host: app.smtpHost, + Port: app.smtpPort, + User: app.smtpUser, + Password: app.smtpPassword, + From: app.smtpFrom, + FromName: app.smtpFromName, + } + + if cfg.Host == "" { + cfg.Host = get("smtp_host") + } + if cfg.Port == 0 { + fmt.Sscanf(get("smtp_port"), "%d", &cfg.Port) + } + if cfg.User == "" { + cfg.User = get("smtp_user") + } + if cfg.Password == "" { + cfg.Password = get("smtp_password") + } + if cfg.From == "" { + cfg.From = get("smtp_from") + } + if cfg.FromName == "" { + cfg.FromName = get("smtp_from_name") + } + if cfg.Port == 0 { + cfg.Port = 587 + } + return cfg +} + +// sendEmail delivers a plain-text email. +// Uses implicit TLS on port 465, STARTTLS on all other ports. +func sendEmail(cfg SMTPConfig, to, subject, body string) error { + fromHeader := cfg.From + if cfg.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From) + } + msg := fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + fromHeader, to, subject, body, + ) + + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host) + + if cfg.Port == 465 { + tlsCfg := &tls.Config{ServerName: cfg.Host} + conn, err := tls.Dial("tcp", addr, tlsCfg) + if err != nil { + return fmt.Errorf("tls dial: %w", err) + } + c, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer c.Close() + if err = c.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + if err = c.Mail(cfg.From); err != nil { + return fmt.Errorf("smtp mail from: %w", err) + } + if err = c.Rcpt(to); err != nil { + return fmt.Errorf("smtp rcpt: %w", err) + } + w, err := c.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err = fmt.Fprint(w, msg); err != nil { + return err + } + return w.Close() + } + + return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) +} + +// sendTokenEmail sends a volunteer token link to the attendee's email address. +func (app *App) sendTokenEmail(a Attendee) error { + if a.Email == "" { + return fmt.Errorf("attendee has no email address") + } + if a.VolunteerToken == nil || *a.VolunteerToken == "" { + return fmt.Errorf("attendee has no volunteer token") + } + + cfg := app.loadSMTPConfig() + + baseURL := app.baseURL + if baseURL == "" { + app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) + } + baseURL = strings.TrimRight(baseURL, "/") + + event, _ := app.getEvent() + eventName := "the event" + if event != nil && event.Name != "" { + eventName = event.Name + } + + link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) + subject := fmt.Sprintf("Your volunteer link for %s", eventName) + body := fmt.Sprintf( + "Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n", + a.Name, eventName, *a.VolunteerToken, link, + ) + + return sendEmail(cfg, a.Email, subject, body) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4f4b0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772419343, + "narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d048a54 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + description = "Turnpike — event attendee & volunteer management"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + gopls + gotools + nodejs_22 + nodePackages.npm + ]; + + shellHook = '' + echo "turnpike dev — go $(go version | awk '{print $3}'), node $(node --version)" + ''; + }; + }); +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..bdef820 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..54a2631 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,43 @@ +# Svelte + Vite + +This template should help get you started developing with Svelte in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + +This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `checkJs` in the JS template?** + +It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```js +// store.js +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f744ead --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Turnpike + + +
+ + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..c7a0b10 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + /** + * svelte-preprocess cannot figure out whether you have + * a value or a type, so tell TypeScript to enforce using + * `import type` instead of `import` for Types. + */ + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + /** + * To have warnings / errors of the Svelte compiler at the + * correct position, enable source maps by default. + */ + "sourceMap": true, + "esModuleInterop": true, + "types": ["vite/client"], + "skipLibCheck": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable this if you'd like to use dynamic types. + */ + "checkJs": true + }, + /** + * Use global.d.ts instead of compilerOptions.types + * to avoid limiting type declarations. + */ + "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d613636 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1391 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "dexie": "^4.3.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "svelte": "^5.45.2", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dexie": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz", + "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", + "license": "Apache-2.0" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.53.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", + "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9ca9526 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "svelte": "^5.45.2", + "vite": "^7.3.1" + }, + "dependencies": { + "dexie": "^4.3.0" + } +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..f6db49b --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Turnpike", + "short_name": "Turnpike", + "description": "Event attendee & volunteer management", + "start_url": "/", + "display": "standalone", + "background_color": "#0f1117", + "theme_color": "#6366f1", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..a2cdf76 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,102 @@ + + +{#if loading} + +{:else if kioskToken} + +{:else if !session} + +{:else if role === 'gate'} + + +{:else} +
+
+{/if} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..c6f6e11 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,140 @@ +import { db } from './db.js' + +async function getToken() { + const session = await db.session.get(1) + return session?.token ?? null +} + +export async function apiFetch(path, options = {}) { + const token = await getToken() + const headers = {} + // Don't set Content-Type for FormData — browser sets it with correct boundary + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json' + } + Object.assign(headers, options.headers) + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(path, { ...options, headers }) + if (res.status === 401) { + await db.session.clear() + window.location.hash = '#/login' + throw new Error('unauthorized') + } + return res +} + +export async function apiJSON(path, options = {}) { + const res = await apiFetch(path, options) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + return res.json() +} + +// Unauthenticated fetch for the kiosk (no JWT, no redirect on 401) +async function kioskFetch(path, options = {}) { + const headers = { 'Content-Type': 'application/json', ...options.headers } + const res = await fetch(path, { ...options, headers }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + const e = new Error(err.error || res.statusText) + e.status = res.status + e.body = err + throw e + } + return res.json() +} + +export const api = { + login: (username, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + logout: () => apiFetch('/api/logout', { method: 'POST' }), + me: () => apiJSON('/api/me'), + event: { + get: () => apiJSON('/api/event'), + update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), + }, + attendees: { + list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), + get: (id) => apiJSON(`/api/attendees/${id}`), + create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }), + checkIn: (id, opts = {}) => + apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }), + generateTokens: () => + apiJSON('/api/attendees/generate-tokens', { method: 'POST' }), + emailToken: (id) => + apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }), + emailAllTokens: () => + apiJSON('/api/attendees/email-tokens', { method: 'POST' }), + }, + volunteers: { + list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), + get: (id) => apiJSON(`/api/volunteers/${id}`), + create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }), + checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }), + assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }), + unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }), + }, + departments: { + list: () => apiJSON('/api/departments'), + create: (data) => apiJSON('/api/departments', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/departments/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/departments/${id}`, { method: 'DELETE' }), + }, + shifts: { + list: (params = {}) => apiJSON('/api/shifts?' + new URLSearchParams(params)), + create: (data) => apiJSON('/api/shifts', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/shifts/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/shifts/${id}`, { method: 'DELETE' }), + assignVolunteer: (shiftId, volunteerId, force = false) => + apiFetch(`/api/shifts/${shiftId}/volunteers`, { method: 'POST', body: JSON.stringify({ volunteer_id: volunteerId, force }) }), + unassignVolunteer: (shiftId, volunteerId) => + apiFetch(`/api/shifts/${shiftId}/volunteers/${volunteerId}`, { method: 'DELETE' }), + reorder: (positions) => + apiFetch('/api/shifts/reorder', { method: 'POST', body: JSON.stringify(positions) }), + }, + users: { + list: () => apiJSON('/api/users'), + create: (data) => apiJSON('/api/users', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/users/${id}`, { method: 'DELETE' }), + }, + settings: { + get: () => apiJSON('/api/settings'), + update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }), + testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }), + }, + import: async (formData) => { + const res = await apiFetch('/api/import', { method: 'POST', body: formData }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + return res.json() + }, + sync: { + pull: (since) => apiJSON('/api/sync/pull' + (since ? `?since=${encodeURIComponent(since)}` : '')), + }, + kiosk: { + get: (token) => kioskFetch(`/api/v/${token}`), + // claim returns {conflict: true, conflicting_shifts: [...]} on 409, or the updated kiosk state on success. + claim: async (token, shiftId, force = false) => { + const res = await fetch(`/api/v/${token}/shifts/${shiftId}${force ? '?force=true' : ''}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + const body = await res.json().catch(() => ({})) + if (res.status === 409) return { conflict: true, ...body } + if (!res.ok) throw new Error(body.error || res.statusText) + return body + }, + unclaim: (token, shiftId) => + kioskFetch(`/api/v/${token}/shifts/${shiftId}`, { method: 'DELETE' }), + }, +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..464472c --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,177 @@ +:root { + --c-bg: #0f1117; + --c-surface: #1a1d27; + --c-border: #2a2d3a; + --c-text: #e2e4ed; + --c-muted: #7a7f96; + --c-accent: #6366f1; + --c-accent-h: #818cf8; + --c-success: #22c55e; + --c-warn: #f59e0b; + --c-danger: #ef4444; + + --radius: 6px; + --radius-lg: 10px; + --font: system-ui, -apple-system, sans-serif; + --transition: 150ms ease; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--c-bg); + color: var(--c-text); + font-family: var(--font); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--c-accent); text-decoration: none; } +a:hover { color: var(--c-accent-h); } + +/* Layout */ +.layout { display: flex; height: 100vh; overflow: hidden; } +.sidebar { + width: 220px; flex-shrink: 0; + background: var(--c-surface); + border-right: 1px solid var(--c-border); + display: flex; flex-direction: column; + padding: 1.5rem 0; +} +.sidebar-brand { + font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; + padding: 0 1.25rem 1.25rem; + border-bottom: 1px solid var(--c-border); + margin-bottom: 0.5rem; + color: var(--c-text); +} +.sidebar-brand span { color: var(--c-accent); } +.nav-link { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.5rem 1.25rem; + color: var(--c-muted); font-size: 0.9rem; + transition: color var(--transition), background var(--transition); +} +.nav-link:hover { color: var(--c-text); background: rgba(255,255,255,0.04); } +.nav-link.active { color: var(--c-text); background: rgba(99,102,241,0.12); } +.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } +.page { padding: 2rem; flex: 1; } +.page-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 1.5rem; +} +.page-title { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; } + +/* Cards */ +.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } + +/* Stats */ +.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } +.stat { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; } +.stat-label { font-size: 0.78rem; color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em; } +.stat-value { font-size: 2rem; font-weight: 700; margin-top: 0.2rem; letter-spacing: -0.03em; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.45rem 1rem; border-radius: var(--radius); + border: 1px solid transparent; + font-size: 0.875rem; font-weight: 500; font-family: var(--font); + cursor: pointer; white-space: nowrap; + transition: background var(--transition), border-color var(--transition), opacity var(--transition); +} +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary { background: var(--c-accent); color: #fff; } +.btn-primary:hover:not(:disabled) { background: var(--c-accent-h); } +.btn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); } +.btn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); } +.btn-danger { background: transparent; color: var(--c-danger); border-color: var(--c-danger); } +.btn-danger:hover:not(:disabled) { background: var(--c-danger); color: #fff; } +.btn-success { background: var(--c-success); color: #000; font-weight: 600; } +.btn-success:hover:not(:disabled) { filter: brightness(1.1); } +.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; } + +/* Forms */ +.form-group { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1rem; } +label { font-size: 0.82rem; color: var(--c-muted); font-weight: 500; } +input, select, textarea { + background: var(--c-bg); border: 1px solid var(--c-border); + border-radius: var(--radius); color: var(--c-text); + font-size: 0.9rem; padding: 0.5rem 0.75rem; + width: 100%; font-family: var(--font); + transition: border-color var(--transition); +} +input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } +input::placeholder { color: var(--c-muted); } + +/* Search */ +.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } +.search-bar input { max-width: 320px; } + +/* Table */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +th { + text-align: left; font-size: 0.75rem; font-weight: 600; + color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.06em; + padding: 0.6rem 1rem; border-bottom: 1px solid var(--c-border); +} +td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--c-border); vertical-align: middle; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(255,255,255,0.02); } + +/* Badges */ +.badge { + display: inline-flex; align-items: center; + padding: 0.18rem 0.55rem; border-radius: 99px; + font-size: 0.72rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.04em; +} +.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } +.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } +.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } +.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); } + +/* Alerts */ +.alert { padding: 0.75rem 1rem; border-radius: var(--radius); font-size: 0.875rem; margin-bottom: 1rem; } +.alert-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; } +.alert-success { background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); color: #86efac; } + +/* Sync indicator */ +.sync-bar { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.5rem 1.25rem; margin-top: auto; + font-size: 0.78rem; color: var(--c-muted); + border-top: 1px solid var(--c-border); +} +.sync-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--c-muted); } +.sync-dot.online { background: var(--c-success); } +.sync-dot.syncing { background: var(--c-warn); animation: pulse 1s infinite; } +.sync-dot.offline { background: var(--c-danger); } + +/* Login */ +.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; } +.login-box { width: 100%; max-width: 380px; } +.login-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; } +.login-sub { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 2rem; } + +/* Misc */ +.dept-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.empty { text-align: center; padding: 3rem 1rem; color: var(--c-muted); } +.empty p { margin-top: 0.5rem; font-size: 0.875rem; } +.text-muted { color: var(--c-muted); } +.text-success { color: var(--c-success); } +.text-danger { color: var(--c-danger); } +.flex { display: flex; align-items: center; } +.spacer { flex: 1; } +.actions { display: flex; gap: 0.4rem; align-items: center; } + +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +@media (max-width: 640px) { + .sidebar { display: none; } + .page { padding: 1rem; } + .stats { grid-template-columns: repeat(2, 1fr); } +} diff --git a/frontend/src/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte new file mode 100644 index 0000000..b3ce533 --- /dev/null +++ b/frontend/src/components/CheckInButton.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte new file mode 100644 index 0000000..f1ccede --- /dev/null +++ b/frontend/src/components/Nav.svelte @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/components/SyncStatus.svelte b/frontend/src/components/SyncStatus.svelte new file mode 100644 index 0000000..1fa1af6 --- /dev/null +++ b/frontend/src/components/SyncStatus.svelte @@ -0,0 +1,46 @@ + + +
+
+ {label} + Last sync: {lastSyncLabel} + {#if online && !syncing} + + {/if} +
diff --git a/frontend/src/db.js b/frontend/src/db.js new file mode 100644 index 0000000..258c76c --- /dev/null +++ b/frontend/src/db.js @@ -0,0 +1,49 @@ +import Dexie from 'dexie' + +export const db = new Dexie('turnpike') + +db.version(1).stores({ + session: 'id, token, user', + meta: 'key', + event: 'id', + attendees: 'id, name, ticket_type, checked_in, deleted_at', + departments: 'id, name, deleted_at', + volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at', + shifts: 'id, department_id, day, deleted_at', + volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id', + outbox: '++id, table, op, synced_at', +}) + +db.version(2).stores({ + session: 'id, token, user', + meta: 'key', + event: 'id', + attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at', + departments: 'id, name, deleted_at', + volunteers: 'id, name, department_id, checked_in, attendee_id, deleted_at', + shifts: 'id, department_id, day, position, deleted_at', + volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id', + outbox: '++id, table, op, synced_at', +}) + +export async function getLastSync() { + const m = await db.meta.get('last_sync') + return m?.value ?? '' +} + +export async function setLastSync(ts) { + await db.meta.put({ key: 'last_sync', value: ts }) +} + +export async function getSession() { + return db.session.get(1) +} + +export async function saveSession(token, user) { + await db.session.put({ id: 1, token, user }) +} + +export async function clearSession() { + await db.session.clear() + await db.meta.clear() +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..458c7a8 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app'), +}) + +export default app diff --git a/frontend/src/pages/Attendees.svelte b/frontend/src/pages/Attendees.svelte new file mode 100644 index 0000000..72619b8 --- /dev/null +++ b/frontend/src/pages/Attendees.svelte @@ -0,0 +1,274 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+ {/if} + + {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {/if} + + + + {#if ($allAttendees ?? []).length === 0} +
+ No attendees yet +

Import a CSV or add attendees manually.

+
+ {:else} +
+ + + + + + + + {#if canCheckIn}{/if} + + + + {#each filtered as a (a.id)} + + + + + + {#if canCheckIn} + + {/if} + + {/each} + +
NameTicket typeEmailStatus
+ {a.name} + {#if a.ticket_id} + · {a.ticket_id} + {/if} + {#if (a.party_size ?? 1) > 1} + ×{a.party_size} + {/if} + {#if a.note} +
{a.note}
+ {/if} +
{a.ticket_type || '—'} +
{a.email || '—'}
+ {#if a.volunteer_token && canManage} +
+ {a.volunteer_token} + {#if a.email} + + {/if} +
+ {/if} +
+ {#if (a.party_size ?? 1) > 1} + + {a.checked_in_count ?? 0}/{a.party_size} in + + {:else} + + {a.checked_in ? 'Checked in' : 'Pending'} + + {/if} + {#if a.checked_in_at} +
+ {new Date(a.checked_in_at).toLocaleTimeString()} +
+ {/if} +
+ {#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)} + checkIn(a)} /> + {/if} +
+
+ {/if} +
diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte new file mode 100644 index 0000000..c1ae495 --- /dev/null +++ b/frontend/src/pages/Dashboard.svelte @@ -0,0 +1,62 @@ + + +
+ + + {#if $event?.start_date} +

+ {$event.start_date}{$event.end_date !== $event.start_date ? ` – ${$event.end_date}` : ''} + {#if $event.timezone} · {$event.timezone}{/if} +

+ {/if} + +
+
+
Total
+
{total}
+
+
+
Checked in
+
{checkedIn}
+
+
+
Remaining
+
{remaining}
+
+
+
Progress
+
{pct}%
+
+
+ + {#if total > 0} +
+
+
+
+
+ {/if} + +

+ Welcome, {session?.user?.username} + · {session?.user?.role} +

+
diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte new file mode 100644 index 0000000..81408d8 --- /dev/null +++ b/frontend/src/pages/Departments.svelte @@ -0,0 +1,190 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if showAdd && canCreate} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ {/if} + + {#if ($allDepts ?? []).length === 0} +
+ No departments yet +

Add departments to organize your volunteer teams.

+
+ {:else} +
+ + + + + + {#if canCreate}{/if} + + + + {#each $allDepts ?? [] as d (d.id)} + {#if editID === d.id} + + + + {#if canCreate} + + {/if} + + {:else} + + + + {#if canCreate} + + {/if} + + {/if} + {/each} + +
DepartmentDescription
+
+ + +
+
+ + +
+ + +
+
+ + {d.name} + {d.description || '—'} +
+ + {#if canDelete} + + {/if} +
+
+
+ {/if} +
diff --git a/frontend/src/pages/GateUI.svelte b/frontend/src/pages/GateUI.svelte new file mode 100644 index 0000000..f3ea8a9 --- /dev/null +++ b/frontend/src/pages/GateUI.svelte @@ -0,0 +1,451 @@ + + +
+
+
Turnpike  Gate Check-in
+ +
+ +
+ +
+ + {#if qrSupported} + + {/if} +
+ + {#if scanning} +
+ +
Point camera at QR code on ticket
+
+ {/if} + + {#if scannerMsg} +
{scannerMsg}
+ {/if} + + {#if error} +
{error}
+ {/if} + + + {#if selected} + {@const rem = remaining(selected)} + {@const prog = progressLabel(selected)} +
+
{selected.name}
+ {#if selected.ticket_type} +
{selected.ticket_type}
+ {/if} + {#if selected.ticket_id} +
#{selected.ticket_id}
+ {/if} + {#if prog} +
+ {prog} +
+ {/if} + +
+ {#if rem > 0} + + {#if rem > 1} + + {/if} + {:else} + All checked in + {/if} + + {#if selected.volunteer_token && !selected.checked_in} + + {/if} +
+
+ {:else if search.trim().length >= 2 && filtered.length > 1} + +
+ {#each filtered as a} + + {/each} +
+ {:else if search.trim().length >= 2 && filtered.length === 0} +
No matching attendees found.
+ {/if} + + +
+
Recent Check-ins
+ {#if ($recentCheckIns ?? []).length === 0} +
No check-ins yet today.
+ {:else} + {#each $recentCheckIns ?? [] as a} +
+ {a.name} + {fmt(a.checked_in_at)} +
+ {/each} + {/if} +
+
+
+ + diff --git a/frontend/src/pages/Import.svelte b/frontend/src/pages/Import.svelte new file mode 100644 index 0000000..a3f9ccc --- /dev/null +++ b/frontend/src/pages/Import.svelte @@ -0,0 +1,96 @@ + + +
+ + +
+
+
+ + +
+ +
+ Supported formats:
+ CrowdWork / ticketing platform: columns Patron Name, Patron Email, Tier Name, Order Number
+ Generic: columns name, email, ticket_id, ticket_type, note
+ Duplicate names are skipped. +
+ + +
+
+ + {#if error} +
{error}
+ {/if} + + {#if result} +
+
+
+
Imported
+
{result.inserted}
+
+
+
Skipped
+
{result.skipped}
+
+ {#if result.errors?.length} +
+
Errors
+
{result.errors.length}
+
+ {/if} +
+ {#if result.errors?.length} +
+ {#each result.errors as err} +
{err}
+ {/each} +
+ {/if} +
+ {/if} +
diff --git a/frontend/src/pages/Kiosk.svelte b/frontend/src/pages/Kiosk.svelte new file mode 100644 index 0000000..3f6aa27 --- /dev/null +++ b/frontend/src/pages/Kiosk.svelte @@ -0,0 +1,356 @@ + + +
+
+
Turnpike  Volunteer Portal
+
+ + {#if !token} +
+
No volunteer token found in URL.
Check the link you were sent.
+
+ {:else if loading} +
Loading…
+ {:else if error && !state} +
+
{error}
+
+ {:else if state} +
+ {#if error} +
{error}
+ {/if} + + + {#if conflictShift} +
+
+

Scheduling Conflict

+

+ {conflictShift.name} ({fmt(conflictShift.start_time)}–{fmt(conflictShift.end_time)}) + overlaps with: +

+
    + {#each conflictingShifts as s} +
  • {s.name} — {fmt(s.start_time)}–{fmt(s.end_time)}
  • + {/each} +
+

You can still sign up — just confirm you're aware of the overlap.

+
+ + +
+
+
+ {/if} + + +
+
{state.volunteer.name}
+
+ {state.volunteer.email || ''} + {state.volunteer.is_lead ? ' · Department Lead' : ''} +
+
Token: {token}
+
+ + + {#if state.shifts.length > 0} +
+

My Shifts

+ {#each groupByDay(state.shifts) as [day, shifts]} +
{day}
+ {#each shifts as s} +
+
+ {s.name} + {fmt(s.start_time)} – {fmt(s.end_time)} +
+ +
+ {/each} + {/each} +
+ {:else} +
You haven't signed up for any shifts yet.
+ {/if} + + + {#if state.available.length > 0} +
+

Available Shifts

+ {#each groupByDay(state.available) as [day, shifts]} +
{day}
+ {#each shifts as s} + {@const assigned = isAssigned(s.id)} + {#if !assigned} +
+
+ {s.name} + {fmt(s.start_time)} – {fmt(s.end_time)} + {#if s.capacity > 0} + + {s.capacity} spots + + {/if} +
+ +
+ {/if} + {/each} + {/each} +
+ {:else if state.shifts.length === 0} +
No shifts are currently available in your department.
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/pages/Login.svelte b/frontend/src/pages/Login.svelte new file mode 100644 index 0000000..de4f6af --- /dev/null +++ b/frontend/src/pages/Login.svelte @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte new file mode 100644 index 0000000..679469e --- /dev/null +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -0,0 +1,452 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if ($allShifts ?? []).length === 0} +
+ No shifts yet +

Create shifts in the Shifts page first.

+
+ {:else} + {#each board as { dept, days }} + {#if days.length > 0} +
+
+ + {dept.name} +
+ + {#each days as [day, rows]} +
{day}
+ + {#each rows as { shift, assigned, hasConflict }, i} +
+ {#if editShiftID === shift.id} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ {:else} +
+
+ {shift.name} + {fmt(shift.start_time)}–{fmt(shift.end_time)} + {#if shift.capacity > 0} + + {assigned.length}/{shift.capacity} + + {:else if assigned.length > 0} + {assigned.length} + {/if} + {#if hasConflict} + ⚠ conflict + {/if} +
+ +
+ + + +
+
+ + + {#if assigned.length > 0} +
+ {#each assigned as { vs, volunteer }} +
+ {volunteer.name} + {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} + + {/if} + +
+ {/each} +
+ {/if} + + + {#if assigningShiftID === shift.id} +
+ + + + +
+ {:else} + + {/if} + {/if} +
+ {/each} + {/each} +
+ {/if} + {/each} + {/if} +
+ + diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte new file mode 100644 index 0000000..060aefd --- /dev/null +++ b/frontend/src/pages/Settings.svelte @@ -0,0 +1,151 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+ {/if} + + {#if loading} +
Loading…
+ {:else} +
+
+

SMTP Email

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+ + +
+

Test Email

+
+
+ + +
+ +
+
+ {/if} +
diff --git a/frontend/src/pages/Shifts.svelte b/frontend/src/pages/Shifts.svelte new file mode 100644 index 0000000..e912491 --- /dev/null +++ b/frontend/src/pages/Shifts.svelte @@ -0,0 +1,221 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ {/if} + + {#if ($allShifts ?? []).length === 0} +
+ No shifts yet +

Add shifts to schedule your volunteers.

+
+ {:else} + {#each grouped as { dept, days }} +
+
+ + {dept.name} +
+ {#each days as { day, shifts }} +
+
+ {formatDay(day)} +
+
+ + + {#each shifts as s (s.id)} + + + + + {#if canManage} + + {/if} + + {/each} + +
{s.name}{formatTime(s.start_time)} – {formatTime(s.end_time)} + {#if s.capacity} + Capacity: {s.capacity} + {:else} + Unlimited + {/if} + + +
+
+
+ {/each} +
+ {/each} + {#if ungrouped.length > 0} +
+ {ungrouped.length} shift(s) with unknown departments +
+ {/if} + {/if} +
diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte new file mode 100644 index 0000000..f141db1 --- /dev/null +++ b/frontend/src/pages/Users.svelte @@ -0,0 +1,266 @@ + + +
+ + + {#if loadError} +
{loadError}
+ {/if} + {#if error} +
{error}
+ {/if} + + {#if showAdd} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ {#if ($allDepts ?? []).length > 0} +
+ Departments +
+ {#each $allDepts ?? [] as d} + + {/each} +
+
+ {/if} +
+ + +
+
+
+ {/if} + + {#if loading} +
Loading…
+ {:else if users.length === 0} +
+ No users yet +
+ {:else} +
+ + + + + + + + + + + {#each users as u (u.id)} + {#if editID === u.id} + + + + + + + {:else} + + + + + + + {/if} + {/each} + +
UsernameRoleDepartments
{u.username} {#if u.id === me}you{/if} + + + {#if ($allDepts ?? []).length > 0} +
+ {#each $allDepts ?? [] as d} + + {/each} +
+ {/if} + +
+
+ + +
+
+ {u.username} + {#if u.id === me} + you + {/if} + {roleLabel(u.role)}{deptNamesFor(u.department_ids || [])} +
+ + {#if u.id !== me} + + {/if} +
+
+
+ {/if} +
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte new file mode 100644 index 0000000..2614272 --- /dev/null +++ b/frontend/src/pages/Volunteers.svelte @@ -0,0 +1,241 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ {/if} + + + + {#if ($allVolunteers ?? []).length === 0} +
+ No volunteers yet +

Add volunteers manually.

+
+ {:else} +
+ + + + + + + + {#if canManage}{/if} + + + + {#each filtered as v (v.id)} + {@const dept = deptFor(v.department_id)} + + + + + + {#if canManage} + + {/if} + + {/each} + +
NameDepartmentStatus
+ {v.name} + {#if v.is_lead} + Lead + {/if} + {#if v.note} +
{v.note}
+ {/if} +
+ {#if dept} + {dept.name} + {:else} + — + {/if} + + + {v.checked_in ? 'Checked in' : 'Pending'} + + {#if v.checked_in_at} +
+ {new Date(v.checked_in_at).toLocaleTimeString()} +
+ {/if} +
+ {#if !v.checked_in} + checkIn(v)} /> + {/if} + + +
+
+ {/if} +
diff --git a/frontend/src/sync.js b/frontend/src/sync.js new file mode 100644 index 0000000..f30c80a --- /dev/null +++ b/frontend/src/sync.js @@ -0,0 +1,115 @@ +import { db, getLastSync, setLastSync } from './db.js' +import { api } from './api.js' + +let syncing = false +let sseSource = null + +export async function syncPull() { + if (syncing) return + syncing = true + try { + const since = await getLastSync() + const data = await api.sync.pull(since) + + await db.transaction('rw', + [db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + if (data.event) { + await db.event.put(data.event) + } + if (data.attendees?.length) { + await db.attendees.bulkPut(data.attendees) + // Purge hard-deleted records from Dexie + const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id) + if (deleted.length) await db.attendees.bulkDelete(deleted) + } + if (data.departments?.length) { + await db.departments.bulkPut(data.departments) + const deleted = data.departments.filter(d => d.deleted_at).map(d => d.id) + if (deleted.length) await db.departments.bulkDelete(deleted) + } + if (data.volunteers?.length) { + await db.volunteers.bulkPut(data.volunteers) + const deleted = data.volunteers.filter(v => v.deleted_at).map(v => v.id) + if (deleted.length) await db.volunteers.bulkDelete(deleted) + } + if (data.shifts?.length) { + await db.shifts.bulkPut(data.shifts) + const deleted = data.shifts.filter(s => s.deleted_at).map(s => s.id) + if (deleted.length) await db.shifts.bulkDelete(deleted) + } + if (data.volunteer_shifts?.length) { + await db.volunteer_shifts.bulkPut(data.volunteer_shifts) + } + } + ) + + await setLastSync(data.server_time) + return true + } catch (err) { + console.warn('Sync pull failed:', err.message) + return false + } finally { + syncing = false + } +} + +export function startSSE(onEvent) { + if (sseSource) return + + const connect = () => { + // Get token synchronously from Dexie — SSE doesn't support headers natively, + // so we pass the token as a query param (acceptable since it's same-origin HTTPS). + db.session.get(1).then(session => { + if (!session?.token) return + + sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`) + + sseSource.onmessage = (e) => { + try { + const payload = JSON.parse(e.data) + if (payload.event === 'checkin') { + // Apply check-in to local Dexie immediately + if (payload.data?.type === 'attendee' && payload.data?.attendee) { + db.attendees.put(payload.data.attendee) + } + if (payload.data?.type === 'volunteer' && payload.data?.volunteer) { + db.volunteers.put(payload.data.volunteer) + } + onEvent?.(payload) + } + } catch {} + } + + sseSource.onerror = () => { + sseSource?.close() + sseSource = null + // Reconnect after 5s + setTimeout(connect, 5000) + } + }) + } + + connect() +} + +export function stopSSE() { + sseSource?.close() + sseSource = null +} + +// Poll for sync when online, with exponential backoff on failure +let syncInterval = null + +export function startSyncLoop(intervalMs = 30000) { + if (syncInterval) return + syncInterval = setInterval(() => { + if (navigator.onLine) syncPull() + }, intervalMs) + window.addEventListener('online', () => syncPull()) +} + +export function stopSyncLoop() { + clearInterval(syncInterval) + syncInterval = null +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..96b3455 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1f9d6aa --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + server: { + proxy: { + '/api': 'http://localhost:8180', + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fef0a1d --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module turnpike + +go 1.24 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.48.0 + modernc.org/sqlite v1.46.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.41.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2bd6df1 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/handle_attendees.go b/handle_attendees.go new file mode 100644 index 0000000..5e732ba --- /dev/null +++ b/handle_attendees.go @@ -0,0 +1,167 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in")) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + types, _ := app.attendeeTicketTypes() + total, checkedIn, _ := app.attendeeCounts() + writeJSON(w, map[string]any{ + "attendees": attendees, + "ticket_types": types, + "total": total, + "checked_in": checkedIn, + }) +} + +func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) { + var a Attendee + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if a.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + created, err := app.createAttendee(a) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + a, err := app.getAttendee(id) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, a) +} + +func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var a Attendee + if err := json.NewDecoder(r.Body).Decode(&a); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if a.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + a.ID = id + if err := app.updateAttendee(a); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getAttendee(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteAttendee(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleCheckInAttendee handles POST /api/attendees/:id/checkin. +// Optional body: {"count": N, "also_volunteer": true} +// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true +// and the attendee has a linked volunteer record. +func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + + var body struct { + Count int `json:"count"` + AlsoVolunteer bool `json:"also_volunteer"` + } + body.Count = 1 + json.NewDecoder(r.Body).Decode(&body) + if body.Count < 1 { + body.Count = 1 + } + + claims := claimsFromContext(r) + a, err := app.checkInAttendee(id, claims.UserID, body.Count) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + result := map[string]any{"attendee": a} + + if body.AlsoVolunteer { + v, _ := app.getVolunteerByAttendeeID(id) + if v != nil { + if !v.CheckedIn { + if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil { + result["volunteer"] = v2 + app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2}) + } + } else { + result["volunteer"] = v + } + } + } + + app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a}) + writeJSON(w, result) +} + +func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) { + attendees, err := app.listAttendees("", "", "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`) + wr := csv.NewWriter(w) + wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"}) + for _, a := range attendees { + ci := "no" + if a.CheckedIn { + ci = "yes" + } + wr.Write([]string{ + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, + strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount), + a.Note, ci, + }) + } + wr.Flush() +} diff --git a/handle_auth.go b/handle_auth.go new file mode 100644 index 0000000..282bd85 --- /dev/null +++ b/handle_auth.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + + user, hash, err := app.getUserByUsername(body.Username) + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + if user == nil || !checkPassword(hash, body.Password) { + writeError(w, "invalid credentials", http.StatusUnauthorized) + return + } + + token, err := app.signToken(user) + if err != nil { + writeError(w, "token error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{"token": token, "user": user}) +} + +func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]string{"ok": "logged out"}) +} + +func (app *App) handleMe(w http.ResponseWriter, r *http.Request) { + claims := claimsFromContext(r) + user, err := app.getUserByID(claims.UserID) + if err != nil || user == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, user) +} diff --git a/handle_departments.go b/handle_departments.go new file mode 100644 index 0000000..657503e --- /dev/null +++ b/handle_departments.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListDepartments(w http.ResponseWriter, r *http.Request) { + depts, err := app.listDepartments("") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, depts) +} + +func (app *App) handleCreateDepartment(w http.ResponseWriter, r *http.Request) { + var d Department + if err := json.NewDecoder(r.Body).Decode(&d); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if d.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + if d.Color == "" { + d.Color = "#6366f1" + } + created, err := app.createDepartment(d) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleUpdateDepartment(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var d Department + if err := json.NewDecoder(r.Body).Decode(&d); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if d.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + d.ID = id + if err := app.updateDepartment(d); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getDepartment(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteDepartment(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteDepartment(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/handle_event.go b/handle_event.go new file mode 100644 index 0000000..dd3cc86 --- /dev/null +++ b/handle_event.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +func (app *App) handleGetEvent(w http.ResponseWriter, r *http.Request) { + event, err := app.getEvent() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + if event == nil { + writeJSON(w, map[string]any{}) + return + } + writeJSON(w, event) +} + +func (app *App) handleUpdateEvent(w http.ResponseWriter, r *http.Request) { + var e Event + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if e.Name == "" || e.StartDate == "" || e.EndDate == "" { + writeError(w, "name, start_date, and end_date are required", http.StatusBadRequest) + return + } + if err := app.upsertEvent(e); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + event, _ := app.getEvent() + writeJSON(w, event) +} diff --git a/handle_import.go b/handle_import.go new file mode 100644 index 0000000..359d8f9 --- /dev/null +++ b/handle_import.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + "net/http" + "strings" +) + +type ImportResult struct { + Inserted int `json:"inserted"` + Grouped int `json:"grouped"` + Skipped int `json:"skipped"` + Errors []string `json:"errors"` +} + +func (app *App) handleImport(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil { + writeError(w, "invalid form", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("csv") + if err != nil { + writeError(w, "csv file required", http.StatusBadRequest) + return + } + defer file.Close() + + result, err := app.importCSV(file) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if result.Errors == nil { + result.Errors = []string{} + } + writeJSON(w, result) +} + +func (app *App) importCSV(r io.Reader) (ImportResult, error) { + reader := csv.NewReader(r) + reader.TrimLeadingSpace = true + reader.LazyQuotes = true + + header, err := reader.Read() + if err != nil { + return ImportResult{}, fmt.Errorf("reading header: %w", err) + } + if len(header) > 0 { + header[0] = strings.TrimPrefix(header[0], "\xef\xbb\xbf") // strip BOM + } + + colIndex := make(map[string]int) + for i, h := range header { + colIndex[strings.ToLower(strings.TrimSpace(h))] = i + } + + var ( + nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int + hasEmail, hasTicketID, hasTicketType, hasNote bool + ) + + if idx, ok := colIndex["patron name"]; ok { + // CrowdWork / ticketing platform format + nameIdx = idx + if idx, ok := colIndex["patron email"]; ok { + emailIdx, hasEmail = idx, true + } + if idx, ok := colIndex["tier name"]; ok { + ticketTypeIdx, hasTicketType = idx, true + } + if idx, ok := colIndex["order number"]; ok { + ticketIDIdx, hasTicketID = idx, true + } + } else if idx, ok := colIndex["name"]; ok { + // Generic format + nameIdx = idx + if idx, ok := colIndex["email"]; ok { + emailIdx, hasEmail = idx, true + } + if idx, ok := colIndex["ticket_id"]; ok { + ticketIDIdx, hasTicketID = idx, true + } + if idx, ok := colIndex["ticket_type"]; ok { + ticketTypeIdx, hasTicketType = idx, true + } + if idx, ok := colIndex["note"]; ok { + noteIdx, hasNote = idx, true + } + } else { + return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column") + } + + var result ImportResult + lineNum := 1 + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + lineNum++ + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("line %d: %v", lineNum, err)) + continue + } + + name := strings.TrimSpace(csvGet(record, nameIdx)) + if name == "" { + continue + } + + a := Attendee{Name: name} + if hasEmail { + a.Email = strings.TrimSpace(csvGet(record, emailIdx)) + } + if hasTicketID { + a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx)) + } + if hasTicketType { + a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx)) + } + if hasNote { + a.Note = strings.TrimSpace(csvGet(record, noteIdx)) + } + + _, err = app.createAttendee(a) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + // CrowdWork exports one row per ticket under the purchaser's name. + // If we have a ticket_id and the same (name, ticket_id) already exists, + // increment party_size instead of skipping. + if hasTicketID && a.TicketID != "" { + merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID) + if mergeErr == nil && merged { + result.Grouped++ + continue + } + } + result.Skipped++ + } else { + result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err)) + } + continue + } + result.Inserted++ + } + + return result, nil +} + +func csvGet(record []string, idx int) string { + if idx < len(record) { + return record[idx] + } + return "" +} diff --git a/handle_kiosk.go b/handle_kiosk.go new file mode 100644 index 0000000..c23b981 --- /dev/null +++ b/handle_kiosk.go @@ -0,0 +1,132 @@ +package main + +import ( + "net/http" + "strconv" +) + +// handleKioskGet returns the volunteer's profile, current shift assignments, and +// available open shifts in their department. Authenticated by volunteer token only — +// no JWT required. +func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer record linked to this token", http.StatusNotFound) + return + } + + assigned, _ := app.listShiftsForVolunteer(v.ID) + if assigned == nil { + assigned = []Shift{} + } + + var available []Shift + if v.DepartmentID != nil { + available, _ = app.listOpenShiftsForDept(*v.DepartmentID) + } + if available == nil { + available = []Shift{} + } + + writeJSON(w, map[string]any{ + "volunteer": v, + "shifts": assigned, + "available": available, + }) +} + +// handleKioskClaim assigns the volunteer to a shift. +// Without ?force=true it returns 409 with conflicting shifts on overlap. +func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + shiftID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid shift id", http.StatusBadRequest) + return + } + + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } + + force := r.URL.Query().Get("force") == "true" + + if !force { + conflicts, err := app.checkShiftConflict(v.ID, shiftID) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + if len(conflicts) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + writeJSON(w, map[string]any{ + "conflict": true, + "conflicting_shifts": conflicts, + }) + return + } + } + + shift, err := app.getShift(shiftID) + if err != nil || shift == nil { + writeError(w, "shift not found", http.StatusNotFound) + return + } + if shift.Capacity > 0 { + count, _ := app.shiftAssignedCount(shiftID) + if count >= shift.Capacity { + writeError(w, "shift is full", http.StatusConflict) + return + } + } + + if err := app.assignShift(v.ID, shiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + app.handleKioskGet(w, r) +} + +// handleKioskUnclaim removes the volunteer from a shift. +func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + shiftID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid shift id", http.StatusBadRequest) + return + } + + a, err := app.getAttendeeByToken(token) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + v, _ := app.getVolunteerByAttendeeID(a.ID) + if v == nil { + writeError(w, "no volunteer linked to this token", http.StatusNotFound) + return + } + + if err := app.unassignShift(v.ID, shiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + app.handleKioskGet(w, r) +} diff --git a/handle_settings.go b/handle_settings.go new file mode 100644 index 0000000..bf19d13 --- /dev/null +++ b/handle_settings.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) { + cfg := app.loadSMTPConfig() + + baseURL := app.baseURL + if baseURL == "" { + app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) + } + + pass := "" + if cfg.Password != "" { + pass = "***" + } + + writeJSON(w, map[string]any{ + "smtp_host": cfg.Host, + "smtp_port": cfg.Port, + "smtp_user": cfg.User, + "smtp_password": pass, + "smtp_from": cfg.From, + "smtp_from_name": cfg.FromName, + "base_url": baseURL, + }) +} + +func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + + keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"} + for _, k := range keys { + v, ok := body[k] + if !ok { + continue + } + var val string + switch vv := v.(type) { + case string: + if k == "smtp_password" && vv == "" { + continue // don't erase the stored password with an empty value + } + val = vv + case float64: + val = strconv.Itoa(int(vv)) + default: + continue + } + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, k, val) + } + + app.handleGetSettings(w, r) +} + +func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) { + var body struct { + To string `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.To == "" { + writeError(w, "to email address required", http.StatusBadRequest) + return + } + cfg := app.loadSMTPConfig() + if err := sendEmail(cfg, body.To, "Turnpike test email", "This is a test email from your Turnpike instance. SMTP is configured correctly."); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/handle_shifts.go b/handle_shifts.go new file mode 100644 index 0000000..460ed91 --- /dev/null +++ b/handle_shifts.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + var deptID *int + if d := q.Get("dept"); d != "" { + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id + } + } + + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] + } + + shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, shifts) +} + +func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { + var s Shift + if err := json.NewDecoder(r.Body).Decode(&s); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if s.DepartmentID == 0 || s.Name == "" || s.Day == "" || s.StartTime == "" || s.EndTime == "" { + writeError(w, "department_id, name, day, start_time, end_time required", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + created, err := app.createShift(s) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var s Shift + if err := json.NewDecoder(r.Body).Decode(&s); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" { + existing, _ := app.getShift(id) + if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + s.ID = id + if err := app.updateShift(s); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getShift(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteShift(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleAssignShiftVolunteer is the shift-centric assignment endpoint. +// POST /api/shifts/:id/volunteers body: {"volunteer_id": N, "force": false} +// Checks for scheduling conflicts unless force=true. +func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Request) { + shiftID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid shift id", http.StatusBadRequest) + return + } + var body struct { + VolunteerID int `json:"volunteer_id"` + Force bool `json:"force"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.VolunteerID == 0 { + writeError(w, "volunteer_id required", http.StatusBadRequest) + return + } + + if !body.Force { + conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + if len(conflicts) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + writeJSON(w, map[string]any{ + "conflict": true, + "conflicting_shifts": conflicts, + }) + return + } + } + + if err := app.assignShift(body.VolunteerID, shiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleUnassignShiftVolunteer removes a volunteer from a shift. +// DELETE /api/shifts/:id/volunteers/:volunteer_id +func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Request) { + shiftID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid shift id", http.StatusBadRequest) + return + } + volunteerID, err := strconv.Atoi(r.PathValue("volunteer_id")) + if err != nil { + writeError(w, "invalid volunteer id", http.StatusBadRequest) + return + } + if err := app.unassignShift(volunteerID, shiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleReorderShifts bulk-updates shift positions. +// POST /api/shifts/reorder body: [{"id": N, "position": N}, ...] +func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) { + var raw []struct { + ID int `json:"id"` + Position int `json:"position"` + } + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil || len(raw) == 0 { + writeError(w, "array of {id, position} required", http.StatusBadRequest) + return + } + positions := make([]struct{ ID, Position int }, len(raw)) + for i, p := range raw { + positions[i] = struct{ ID, Position int }{p.ID, p.Position} + } + if err := app.reorderShifts(positions); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/handle_sync.go b/handle_sync.go new file mode 100644 index 0000000..78725c5 --- /dev/null +++ b/handle_sync.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/http" + "time" +) + +// handleSyncPull returns all records modified since the `since` query parameter. +// If `since` is empty, returns all records (initial sync). +func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { + since := r.URL.Query().Get("since") + + event, _ := app.getEvent() + attendees, _ := app.attendeesSince(since) + departments, _ := app.listDepartments(since) + volunteers, _ := app.listVolunteers("", nil, since) + shifts, _ := app.listShifts(nil, "", since) + volunteerShifts, _ := app.listVolunteerShifts(since) + + if attendees == nil { + attendees = []Attendee{} + } + if departments == nil { + departments = []Department{} + } + if volunteers == nil { + volunteers = []Volunteer{} + } + if shifts == nil { + shifts = []Shift{} + } + if volunteerShifts == nil { + volunteerShifts = []VolunteerShift{} + } + + writeJSON(w, map[string]any{ + "server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "event": event, + "attendees": attendees, + "departments": departments, + "volunteers": volunteers, + "shifts": shifts, + "volunteer_shifts": volunteerShifts, + }) +} + +// handleSyncStream is an SSE endpoint that broadcasts real-time events +// (check-ins, etc.) to connected clients. +func (app *App) handleSyncStream(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + ch := app.broker.subscribe() + defer app.broker.unsubscribe(ch) + + fmt.Fprintf(w, "data: {\"event\":\"connected\"}\n\n") + flusher.Flush() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case payload, ok := <-ch: + if !ok { + return + } + fmt.Fprintf(w, "data: %s\n\n", payload) + flusher.Flush() + case <-ticker.C: + fmt.Fprintf(w, ": ping\n\n") + flusher.Flush() + } + } +} diff --git a/handle_tokens.go b/handle_tokens.go new file mode 100644 index 0000000..f2c0bf9 --- /dev/null +++ b/handle_tokens.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/csv" + "fmt" + "net/http" + "strconv" + "strings" +) + +// handleGenerateTokens creates volunteer_token values for all attendees that don't have one. +func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) { + count, err := app.generateTokensForAll() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"generated": count}) +} + +// handleExportTokenLinks streams a CSV download with token signup links, +// compatible with MailChimp / Zeffy bulk-send workflows. +func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) { + attendees, err := app.listAttendees("", "", "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + baseURL := app.baseURL + if baseURL == "" { + app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) + } + baseURL = strings.TrimRight(baseURL, "/") + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`) + wr := csv.NewWriter(w) + wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"}) + for _, a := range attendees { + if a.VolunteerToken == nil { + continue + } + firstName := a.Name + if parts := strings.Fields(a.Name); len(parts) > 0 { + firstName = parts[0] + } + link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) + wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link}) + } + wr.Flush() +} + +// handleEmailToken sends a token email to a single attendee. +func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + a, err := app.getAttendee(id) + if err != nil || a == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + if err := app.sendTokenEmail(*a); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email. +func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) { + attendees, err := app.listAttendees("", "", "") + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + var sent, skipped int + var errors []string + for _, a := range attendees { + if a.Email == "" || a.VolunteerToken == nil { + skipped++ + continue + } + if err := app.sendTokenEmail(a); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err)) + skipped++ + } else { + sent++ + } + } + if errors == nil { + errors = []string{} + } + writeJSON(w, map[string]any{"sent": sent, "skipped": skipped, "errors": errors}) +} diff --git a/handle_users.go b/handle_users.go new file mode 100644 index 0000000..386e5b0 --- /dev/null +++ b/handle_users.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { + users, err := app.listUsers() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, users) +} + +func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if body.Username == "" || body.Password == "" || body.Role == "" { + writeError(w, "username, password, and role are required", http.StatusBadRequest) + return + } + hash, err := hashPassword(body.Password) + if err != nil { + writeError(w, "hash error", http.StatusInternalServerError) + return + } + if body.DepartmentIDs == nil { + body.DepartmentIDs = []int{} + } + user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, user) +} + +func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var body struct { + Role string `json:"role"` + Password string `json:"password"` + DepartmentIDs []int `json:"department_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if body.DepartmentIDs == nil { + body.DepartmentIDs = []int{} + } + if body.Role != "" { + if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } + if body.Password != "" { + hash, err := hashPassword(body.Password) + if err != nil { + writeError(w, "hash error", http.StatusInternalServerError) + return + } + if err := app.updateUserPassword(id, hash); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + } + user, _ := app.getUserByID(id) + writeJSON(w, user) +} + +func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + if claims.UserID == id { + writeError(w, "cannot delete yourself", http.StatusBadRequest) + return + } + if err := app.deleteUser(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/handle_volunteers.go b/handle_volunteers.go new file mode 100644 index 0000000..533428e --- /dev/null +++ b/handle_volunteers.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" +) + +func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + search := q.Get("search") + since := q.Get("since") + + var deptID *int + if d := q.Get("dept"); d != "" { + id, err := strconv.Atoi(d) + if err == nil { + deptID = &id + } + } + + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 { + deptID = &claims.DeptIDs[0] + } + + volunteers, err := app.listVolunteers(search, deptID, since) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, volunteers) +} + +func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { + var v Volunteer + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if v.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" { + if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + created, err := app.createVolunteer(v) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + +func (app *App) handleGetVolunteer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + v, err := app.getVolunteer(id) + if err != nil || v == nil { + writeError(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, v) +} + +func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var v Volunteer + if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if v.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + if claims.Role == "volunteer_lead" { + existing, _ := app.getVolunteer(id) + if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + v.ID = id + if err := app.updateVolunteer(v); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + updated, _ := app.getVolunteer(id) + writeJSON(w, updated) +} + +func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := app.deleteVolunteer(id); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + claims := claimsFromContext(r) + v, err := app.checkInVolunteer(id, claims.UserID) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v}) + writeJSON(w, v) +} + +func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { + volunteerID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid volunteer id", http.StatusBadRequest) + return + } + var body struct { + ShiftID int `json:"shift_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ShiftID == 0 { + writeError(w, "shift_id required", http.StatusBadRequest) + return + } + if err := app.assignShift(volunteerID, body.ShiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { + volunteerID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, "invalid volunteer id", http.StatusBadRequest) + return + } + shiftID, err := strconv.Atoi(r.PathValue("shift_id")) + if err != nil { + writeError(w, "invalid shift id", http.StatusBadRequest) + return + } + if err := app.unassignShift(volunteerID, shiftID); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func inSlice(v int, s []int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2fabeb6 --- /dev/null +++ b/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" +) + +//go:embed frontend/dist +var frontendFS embed.FS + +type App struct { + db *sql.DB + secret string + tokenExpiry int + broker *Broker + + // SMTP settings — populated from CLI flags; config table fills any gaps. + smtpHost string + smtpPort int + smtpUser string + smtpPassword string + smtpFrom string + smtpFromName string + baseURL string +} + +func main() { + addr := flag.String("addr", "0.0.0.0:8180", "listen address") + dbPath := flag.String("db", "turnpike.db", "SQLite database path") + secret := flag.String("secret", "", "JWT signing secret (or set TURNPIKE_SECRET)") + tokenExpiry := flag.Int("token-expiry", 24, "JWT expiry in hours") + smtpHost := flag.String("smtp-host", "", "SMTP server hostname") + smtpPort := flag.Int("smtp-port", 0, "SMTP server port (0 = use stored value or 587)") + smtpUser := flag.String("smtp-user", "", "SMTP username") + smtpPass := flag.String("smtp-password", "", "SMTP password") + smtpFrom := flag.String("smtp-from", "", "SMTP from address") + smtpName := flag.String("smtp-from-name", "", "SMTP from display name") + baseURL := flag.String("base-url", "", "Public base URL for volunteer token links (e.g. https://example.com)") + flag.Parse() + + if *secret == "" { + *secret = os.Getenv("TURNPIKE_SECRET") + } + + db, err := initDB(*dbPath) + if err != nil { + log.Fatalf("database init: %v", err) + } + defer db.Close() + + app := &App{ + db: db, + tokenExpiry: *tokenExpiry, + broker: newBroker(), + smtpHost: *smtpHost, + smtpPort: *smtpPort, + smtpUser: *smtpUser, + smtpPassword: *smtpPass, + smtpFrom: *smtpFrom, + smtpFromName: *smtpName, + baseURL: *baseURL, + } + + if *secret == "" { + *secret, err = app.getOrCreateSecret() + if err != nil { + log.Fatalf("secret: %v", err) + } + } + app.secret = *secret + + if err := app.bootstrapAdmin(); err != nil { + log.Fatalf("bootstrap: %v", err) + } + + mux := http.NewServeMux() + app.registerRoutes(mux) + + log.Printf("Turnpike listening on %s", *addr) + log.Fatal(http.ListenAndServe(*addr, mux)) +} + +func (app *App) registerRoutes(mux *http.ServeMux) { + auth := app.requireAuth + + mux.HandleFunc("POST /api/login", app.handleLogin) + mux.HandleFunc("POST /api/logout", auth(app.handleLogout)) + mux.HandleFunc("GET /api/me", auth(app.handleMe)) + + mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) + mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) + + mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing")) + mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate")) + mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing")) + mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing")) + mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate")) + mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing")) + + mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) + mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator")) + mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator")) + mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) + + mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead")) + + mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) + mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead")) + + mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin")) + mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin")) + mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin")) + mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin")) + + mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin")) + mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin")) + mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin")) + + mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) + + mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) + mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) + + // Kiosk — authenticated by volunteer token, no JWT required. + mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet) + mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim) + mux.HandleFunc("DELETE /api/v/{token}/shifts/{id}", app.handleKioskUnclaim) + + // Serve embedded frontend, falling through to index.html for SPA routing. + distFS, err := fs.Sub(frontendFS, "frontend/dist") + if err != nil { + log.Fatalf("embed: %v", err) + } + fileServer := http.FileServer(http.FS(distFS)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path != "/" { + // Strip leading slash and check if file exists + if _, err := fs.Stat(distFS, path[1:]); err == nil { + fileServer.ServeHTTP(w, r) + return + } + } + // All other paths: serve index.html (SPA client-side routing) + r2 := *r + r2.URL.Path = "/" + fileServer.ServeHTTP(w, &r2) + }) +} + +func (app *App) bootstrapAdmin() error { + adminUser := os.Getenv("TURNPIKE_ADMIN_USER") + adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") + if adminUser == "" || adminPass == "" { + return nil + } + n, err := app.countUsers() + if err != nil || n > 0 { + return err + } + hash, err := hashPassword(adminPass) + if err != nil { + return err + } + _, err = app.createUser(adminUser, hash, "admin", []int{}) + if err != nil { + return err + } + log.Printf("Created admin user: %s", adminUser) + return nil +} + +func (app *App) getOrCreateSecret() (string, error) { + app.db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) + var s string + err := app.db.QueryRow(`SELECT value FROM config WHERE key = 'jwt_secret'`).Scan(&s) + if err == nil && s != "" { + return s, nil + } + secret := generateSecret() + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('jwt_secret', ?)`, secret) + log.Printf("Generated new JWT secret and stored in database") + return secret, nil +} + +func generateSecret() string { + b := make([]byte, 32) + rand.Read(b) + return fmt.Sprintf("%x", b) +} From 7fb5716a3597b1ee248fee989176e7eba051c647 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 11:39:15 -0600 Subject: [PATCH 02/16] Updated boilerplate frontend/README.md --- frontend/README.md | 67 ++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 54a2631..36b1fa2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,43 +1,34 @@ -# Svelte + Vite +# Turnpike Frontend -This template should help get you started developing with Svelte in Vite. +Svelte 5 + Vite PWA. Offline-first with Dexie (IndexedDB) and background sync. -## Recommended IDE Setup +## Development -[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). +From the repo root with `direnv allow` (or Node.js 18+ installed): -## Need an official Svelte framework? - -Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. - -## Technical considerations - -**Why use this over SvelteKit?** - -- It brings its own routing solution which might not be preferable for some users. -- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. - -This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. - -Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. - -**Why include `.vscode/extensions.json`?** - -Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. - -**Why enable `checkJs` in the JS template?** - -It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. - -**Why is HMR not preserving my local component state?** - -HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). - -If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. - -```js -// store.js -// An extremely simple external store -import { writable } from 'svelte/store' -export default writable(0) +```sh +cd frontend +npm install +npm run dev ``` + +Runs on `:5173`, proxies `/api` to the Go backend on `:8180`. + +## Build + +```sh +npm run build +``` + +Output goes to `dist/`, which the Go binary embeds at compile time. + +## Architecture + +- `src/db.js` — Dexie schema, session management +- `src/api.js` — all API calls, injects `Authorization: Bearer` header +- `src/sync.js` — sync pull, SSE stream, outbox flush +- `src/pages/` — page components (one per route) +- `src/components/` — shared UI components +- `src/app.css` — global CSS custom properties (colors, spacing, type scale) + +All UI reads come from Dexie via `liveQuery()`, not direct API calls. Styles are scoped per component; no hardcoded color values. From 10037add999fefe37613faa089896b94df1a80cf Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 12:50:24 -0600 Subject: [PATCH 03/16] Added tests, shift 'delete'. Fixed overnight shifts, sync, error handling. --- Makefile | 6 +- auth_test.go | 127 +++++ db.go | 107 ++++- db_test.go | 275 +++++++++++ frontend/package-lock.json | 945 ++++++++++++++++++++++++++++++++++++- frontend/package.json | 9 +- frontend/src/api.test.js | 98 ++++ frontend/src/db.test.js | 49 ++ frontend/src/sync.js | 20 +- frontend/src/sync.test.js | 88 ++++ frontend/src/test-setup.js | 1 + frontend/vite.config.js | 4 + go.mod | 2 +- handle_attendees_test.go | 109 +++++ handle_import_test.go | 142 ++++++ handle_kiosk.go | 10 +- handle_kiosk_test.go | 167 +++++++ handle_settings_test.go | 71 +++ handle_shifts_test.go | 131 +++++ handle_sync_test.go | 110 +++++ testutil_test.go | 91 ++++ 21 files changed, 2522 insertions(+), 40 deletions(-) create mode 100644 auth_test.go create mode 100644 db_test.go create mode 100644 frontend/src/api.test.js create mode 100644 frontend/src/db.test.js create mode 100644 frontend/src/sync.test.js create mode 100644 frontend/src/test-setup.js create mode 100644 handle_attendees_test.go create mode 100644 handle_import_test.go create mode 100644 handle_kiosk_test.go create mode 100644 handle_settings_test.go create mode 100644 handle_shifts_test.go create mode 100644 handle_sync_test.go create mode 100644 testutil_test.go diff --git a/Makefile b/Makefile index 72a39be..43067d4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build frontend-build dev clean +.PHONY: build frontend-build dev clean test build: frontend-build CGO_ENABLED=0 go build -o turnpike . @@ -11,6 +11,10 @@ dev: @echo " Terminal 1: go run . --db dev.db" @echo " Terminal 2: cd frontend && npm run dev" +test: + go test ./... + cd frontend && npx vitest run + clean: rm -f turnpike dev.db rm -rf frontend/dist diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..1a16571 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestLoginValid(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": admin.Username, + "password": "admin123", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["token"] == nil || result["token"] == "" { + t.Error("missing token in response") + } + user, ok := result["user"].(map[string]any) + if !ok || user["username"] != "admin" { + t.Errorf("user = %v", result["user"]) + } +} + +func TestLoginWrongPassword(t *testing.T) { + app := testApp(t) + testAdminUser(t, app) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": "admin", + "password": "wrong", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestLoginNonexistentUser(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testRequest("POST", "/api/login", map[string]string{ + "username": "nobody", + "password": "test", + }) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareNoToken(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testRequest("GET", "/api/me", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareInvalidToken(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/me", nil, "bad-token") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestAuthMiddlewareRoleEnforcement(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Create a gate user — should not be able to access /api/users (admin only) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + + req := testAuthRequest("GET", "/api/users", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +func TestMeEndpoint(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/me", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["username"] != "admin" { + t.Errorf("username = %v", result["username"]) + } +} diff --git a/db.go b/db.go index 2d7b8d2..ef3a339 100644 --- a/db.go +++ b/db.go @@ -134,6 +134,7 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") // Widen the uniqueness constraint from name-only to (name, ticket_id). db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) @@ -245,10 +246,11 @@ type Shift struct { } type VolunteerShift struct { - VolunteerID int `json:"volunteer_id"` - ShiftID int `json:"shift_id"` - Confirmed bool `json:"confirmed"` - UpdatedAt string `json:"updated_at"` + VolunteerID int `json:"volunteer_id"` + ShiftID int `json:"shift_id"` + Confirmed bool `json:"confirmed"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` } // --- Event --- @@ -413,21 +415,28 @@ func (app *App) countUsers() (int, error) { const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -func generateToken() string { +func generateToken() (string, error) { b := make([]byte, 8) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("read random: %w", err) + } result := make([]byte, 8) for i, v := range b { result[i] = tokenChars[int(v)%len(tokenChars)] } - return string(result) + return string(result), nil } func (app *App) generateUniqueToken() (string, error) { for range 10 { - t := generateToken() + t, err := generateToken() + if err != nil { + return "", err + } var count int - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count) + if err := app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count); err != nil { + return "", fmt.Errorf("check token uniqueness: %w", err) + } if count == 0 { return t, nil } @@ -452,13 +461,15 @@ func (app *App) generateTokensForAll() (int, error) { if err != nil { return 0, err } + defer rows.Close() var ids []int for rows.Next() { var id int - rows.Scan(&id) + if err := rows.Scan(&id); err != nil { + return 0, fmt.Errorf("scan attendee id: %w", err) + } ids = append(ids, id) } - rows.Close() count := 0 for _, id := range ids { @@ -912,7 +923,7 @@ func queryShifts(db *sql.DB, q string, args ...any) ([]Shift, error) { // shiftAssignedCount returns the number of volunteers currently assigned to a shift. func (app *App) shiftAssignedCount(shiftID int) (int, error) { var count int - err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ?`, shiftID).Scan(&count) + err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count) return count, err } @@ -927,21 +938,40 @@ func (app *App) checkShiftConflict(volunteerID, shiftID int) ([]Shift, error) { SELECT `+shiftColsS+` FROM shifts s JOIN volunteer_shifts vs ON vs.shift_id = s.id - WHERE vs.volunteer_id = ? AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, + WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, volunteerID, target.Day, shiftID) if err != nil { return nil, err } var conflicts []Shift for _, s := range existing { - // Overlap: one starts before the other ends (HH:MM string comparison works for same-day) - if s.StartTime < target.EndTime && target.StartTime < s.EndTime { + if timesOverlap(s.StartTime, s.EndTime, target.StartTime, target.EndTime) { conflicts = append(conflicts, s) } } return conflicts, nil } +// timesOverlap checks whether two time ranges (HH:MM) overlap, +// correctly handling ranges that span midnight (e.g. 22:00-02:00). +func timesOverlap(startA, endA, startB, endB string) bool { + // A shift spans midnight when its end time is <= its start time. + spansMidnightA := endA <= startA + spansMidnightB := endB <= startB + + switch { + case !spansMidnightA && !spansMidnightB: + return startA < endB && startB < endA + case spansMidnightA && !spansMidnightB: + return startB < endA || startB >= startA + case !spansMidnightA && spansMidnightB: + return startA < endB || startA >= startB + default: + // Both span midnight — they always overlap + return true + } +} + // reorderShifts updates the position field for each given shift. func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { for _, p := range positions { @@ -959,15 +989,48 @@ func (app *App) reorderShifts(positions []struct{ ID, Position int }) error { func (app *App) assignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( `INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) - ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, updated_at=excluded.updated_at`, + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`, volunteerID, shiftID, now(), ) return err } +// assignShiftWithCapacity atomically checks capacity and assigns. +// Returns errShiftFull if the shift is at capacity. +func (app *App) assignShiftWithCapacity(volunteerID, shiftID, capacity int) error { + tx, err := app.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if capacity > 0 { + var count int + if err := tx.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ? AND deleted_at IS NULL`, shiftID).Scan(&count); err != nil { + return err + } + if count >= capacity { + return errShiftFull + } + } + + if _, err := tx.Exec( + `INSERT INTO volunteer_shifts (volunteer_id, shift_id, updated_at) VALUES (?, ?, ?) + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, deleted_at=NULL, updated_at=excluded.updated_at`, + volunteerID, shiftID, now(), + ); err != nil { + return err + } + + return tx.Commit() +} + +var errShiftFull = fmt.Errorf("shift is full") + func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, volunteerID, shiftID, + `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, + now(), now(), volunteerID, shiftID, ) return err } @@ -976,10 +1039,10 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) { var q string var args []any if since != "" { - q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts WHERE updated_at > ?` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE updated_at > ?` args = append(args, since) } else { - q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE deleted_at IS NULL` } rows, err := app.db.Query(q, args...) if err != nil { @@ -990,7 +1053,7 @@ func (app *App) listVolunteerShifts(since string) ([]VolunteerShift, error) { for rows.Next() { var vs VolunteerShift var confirmed int - rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt) + rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt, &vs.DeletedAt) vs.Confirmed = confirmed == 1 result = append(result, vs) } @@ -1003,7 +1066,7 @@ func (app *App) listShiftsForVolunteer(volunteerID int) ([]Shift, error) { SELECT `+shiftColsS+` FROM shifts s JOIN volunteer_shifts vs ON vs.shift_id = s.id - WHERE vs.volunteer_id = ? AND s.deleted_at IS NULL + WHERE vs.volunteer_id = ? AND vs.deleted_at IS NULL AND s.deleted_at IS NULL ORDER BY s.day, s.position, s.start_time`, volunteerID) } @@ -1014,7 +1077,7 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { FROM shifts s WHERE s.department_id = ? AND s.deleted_at IS NULL AND (s.capacity = 0 OR ( - SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id + SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id AND vs.deleted_at IS NULL ) < s.capacity) ORDER BY s.day, s.position, s.start_time`, deptID) } diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..1a4df38 --- /dev/null +++ b/db_test.go @@ -0,0 +1,275 @@ +package main + +import ( + "testing" +) + +func TestMigrate(t *testing.T) { + app := testApp(t) + // Verify tables exist by querying each one + tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + for _, table := range tables { + var count int + err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) + if err != nil { + t.Errorf("table %s: %v", table, err) + } + } +} + +func TestAttendeesCRUD(t *testing.T) { + app := testApp(t) + + a, err := app.createAttendee(Attendee{Name: "Alice", Email: "alice@test.com", TicketType: "GA"}) + if err != nil { + t.Fatal(err) + } + if a.ID == 0 || a.Name != "Alice" { + t.Errorf("create: got %+v", a) + } + + got, err := app.getAttendee(a.ID) + if err != nil || got == nil { + t.Fatal("get: not found") + } + if got.Email != "alice@test.com" { + t.Errorf("get: email = %q", got.Email) + } + + got.Name = "Alice Smith" + if err := app.updateAttendee(*got); err != nil { + t.Fatal(err) + } + got2, _ := app.getAttendee(a.ID) + if got2.Name != "Alice Smith" { + t.Errorf("update: name = %q", got2.Name) + } + + if err := app.deleteAttendee(a.ID); err != nil { + t.Fatal(err) + } + // getAttendee returns soft-deleted records; listAttendees filters them + attendees, _ := app.listAttendees("", "", "") + for _, at := range attendees { + if at.ID == a.ID { + t.Error("delete: still visible in list") + } + } +} + +func TestIncrementPartySize(t *testing.T) { + app := testApp(t) + + app.createAttendee(Attendee{Name: "Bob", TicketID: "ORD-100"}) + + merged, err := app.incrementPartySize("Bob", "ORD-100") + if err != nil || !merged { + t.Fatalf("increment: merged=%v, err=%v", merged, err) + } + + a, _ := app.getAttendee(1) + if a.PartySize != 2 { + t.Errorf("party_size = %d, want 2", a.PartySize) + } + + // Different ticket_id should not merge + merged2, _ := app.incrementPartySize("Bob", "ORD-200") + if merged2 { + t.Error("should not merge different ticket_id") + } +} + +func TestCheckInAttendee(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + + app.createAttendee(Attendee{Name: "Charlie"}) + // Set party_size directly since createAttendee defaults to 1 + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + + // Check in 1 + a, err := app.checkInAttendee(1, admin.ID, 1) + if err != nil { + t.Fatal(err) + } + if a.CheckedInCount != 1 || !a.CheckedIn { + t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn) + } + + // Check in 2 more (should cap at party_size=3) + a, _ = app.checkInAttendee(1, admin.ID, 5) + if a.CheckedInCount != 3 { + t.Errorf("after cap: count=%d, want 3", a.CheckedInCount) + } + + // Check in again — already full, should stay at 3 + a, _ = app.checkInAttendee(1, admin.ID, 1) + if a.CheckedInCount != 3 { + t.Errorf("after full: count=%d, want 3", a.CheckedInCount) + } +} + +func TestGenerateToken(t *testing.T) { + token, err := generateToken() + if err != nil { + t.Fatal(err) + } + if len(token) != 8 { + t.Errorf("token length = %d, want 8", len(token)) + } + for _, c := range token { + if !isValidTokenChar(c) { + t.Errorf("invalid char %c in token %s", c, token) + } + } +} + +func isValidTokenChar(c rune) bool { + for _, tc := range tokenChars { + if c == tc { + return true + } + } + return false +} + +func TestGenerateUniqueToken(t *testing.T) { + app := testApp(t) + token, err := app.generateUniqueToken() + if err != nil || len(token) != 8 { + t.Fatalf("token=%q, err=%v", token, err) + } +} + +func TestDepartmentsCRUD(t *testing.T) { + app := testApp(t) + + d, err := app.createDepartment(Department{Name: "Gate"}) + if err != nil { + t.Fatal(err) + } + if d.Name != "Gate" { + t.Errorf("name = %q", d.Name) + } + + depts, _ := app.listDepartments("") + if len(depts) != 1 { + t.Errorf("list: got %d", len(depts)) + } + + if err := app.deleteDepartment(d.ID); err != nil { + t.Fatal(err) + } +} + +func TestShiftsCRUD(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + s, err := app.createShift(Shift{ + DepartmentID: dept.ID, + Name: "Morning", + Day: "2026-03-15", + StartTime: "08:00", + EndTime: "12:00", + Capacity: 5, + }) + if err != nil { + t.Fatal(err) + } + if s.Name != "Morning" || s.Capacity != 5 { + t.Errorf("create: %+v", s) + } + + got, _ := app.getShift(s.ID) + if got == nil || got.Day != "2026-03-15" { + t.Error("get: not found or wrong day") + } + + if err := app.deleteShift(s.ID); err != nil { + t.Fatal(err) + } +} + +func TestAssignAndUnassignShift(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) + v, _ := app.createVolunteer(Volunteer{Name: "Dana", DepartmentID: &deptID}) + + if err := app.assignShift(v.ID, s.ID); err != nil { + t.Fatal(err) + } + count, _ := app.shiftAssignedCount(s.ID) + if count != 1 { + t.Errorf("assigned count = %d, want 1", count) + } + + if err := app.unassignShift(v.ID, s.ID); err != nil { + t.Fatal(err) + } + count, _ = app.shiftAssignedCount(s.ID) + if count != 0 { + t.Errorf("after unassign: count = %d, want 0", count) + } +} + +func TestCheckShiftConflict(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{Name: "Eve", DepartmentID: &deptID}) + + s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + s3, _ := app.createShift(Shift{DepartmentID: deptID, Name: "NoOverlap", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00"}) + + app.assignShift(v.ID, s1.ID) + + // s2 overlaps s1 (10:00-14:00 vs 08:00-12:00) + conflicts, err := app.checkShiftConflict(v.ID, s2.ID) + if err != nil { + t.Fatal(err) + } + if len(conflicts) != 1 { + t.Errorf("overlap: got %d conflicts, want 1", len(conflicts)) + } + + // s3 does not overlap s1 (14:00-18:00 vs 08:00-12:00) + conflicts, _ = app.checkShiftConflict(v.ID, s3.ID) + if len(conflicts) != 0 { + t.Errorf("no overlap: got %d conflicts, want 0", len(conflicts)) + } +} + +func TestCheckShiftConflictMidnight(t *testing.T) { + app := testApp(t) + + dept, _ := app.createDepartment(Department{Name: "Sound"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{Name: "Frank", DepartmentID: &deptID}) + + // Night shift: 22:00-02:00 (spans midnight) + night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"}) + // Late shift: 23:00-03:00 (overlaps with night) + late, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Late", Day: "2026-03-15", StartTime: "23:00", EndTime: "03:00"}) + // Morning shift: 08:00-12:00 (no overlap with night) + morning, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + app.assignShift(v.ID, night.ID) + + // Late should conflict with night + conflicts, _ := app.checkShiftConflict(v.ID, late.ID) + if len(conflicts) != 1 { + t.Errorf("midnight overlap: got %d conflicts, want 1", len(conflicts)) + } + + // Morning should not conflict with night + conflicts, _ = app.checkShiftConflict(v.ID, morning.ID) + if len(conflicts) != 0 { + t.Errorf("no midnight overlap: got %d conflicts, want 0", len(conflicts)) + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d613636..461f480 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,8 +12,201 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", + "fake-indexeddb": "^6.2.5", + "jsdom": "^28.1.0", "svelte": "^5.45.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -458,6 +651,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -858,6 +1069,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", @@ -907,6 +1125,24 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -921,6 +1157,117 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -934,6 +1281,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -944,6 +1301,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -954,6 +1321,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -964,6 +1351,75 @@ "node": ">=6" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -987,6 +1443,26 @@ "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", "license": "Apache-2.0" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1046,6 +1522,36 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1079,6 +1585,54 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1089,6 +1643,47 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1096,6 +1691,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1106,6 +1711,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1136,6 +1755,26 @@ ], "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1185,6 +1824,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1230,6 +1889,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1240,6 +1919,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.53.6", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", @@ -1268,6 +1961,30 @@ "node": ">=18" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1285,6 +2002,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -1380,6 +2163,166 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9ca9526..68ad1f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,12 +6,17 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", + "fake-indexeddb": "^6.2.5", + "jsdom": "^28.1.0", "svelte": "^5.45.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "dependencies": { "dexie": "^4.3.0" diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js new file mode 100644 index 0000000..fed07ad --- /dev/null +++ b/frontend/src/api.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { db, saveSession, clearSession } from './db.js' + +// Must import api after fake-indexeddb is initialized (via test-setup.js) +const { apiFetch, apiJSON, api } = await import('./api.js') + +beforeEach(async () => { + await Promise.all(db.tables.map(t => t.clear())) + vi.restoreAllMocks() +}) + +function mockFetch(body = {}, status = 200) { + const fn = vi.fn(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }) + ) + globalThis.fetch = fn + return fn +} + +describe('apiFetch', () => { + it('adds Authorization header when session exists', async () => { + await saveSession('mytoken', { id: 1 }) + const f = mockFetch() + await apiFetch('/api/test') + expect(f).toHaveBeenCalledTimes(1) + const [, opts] = f.mock.calls[0] + expect(opts.headers['Authorization']).toBe('Bearer mytoken') + }) + + it('omits Authorization when no session', async () => { + const f = mockFetch() + await apiFetch('/api/test') + const [, opts] = f.mock.calls[0] + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('clears session on 401', async () => { + await saveSession('expired', { id: 1 }) + mockFetch({}, 401) + await expect(apiFetch('/api/test')).rejects.toThrow('unauthorized') + expect(await db.session.get(1)).toBeUndefined() + }) +}) + +describe('apiJSON', () => { + it('parses JSON response', async () => { + mockFetch({ name: 'Alice' }) + const result = await apiJSON('/api/test') + expect(result.name).toBe('Alice') + }) + + it('throws on non-OK response', async () => { + mockFetch({ error: 'not found' }, 404) + await expect(apiJSON('/api/test')).rejects.toThrow('not found') + }) +}) + +describe('api methods', () => { + it('login calls correct endpoint', async () => { + const f = mockFetch({ token: 'tok', user: { id: 1 } }) + await api.login('admin', 'pass') + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/login') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' }) + }) + + it('attendees.list calls correct endpoint', async () => { + const f = mockFetch({ attendees: [] }) + await api.attendees.list({ search: 'test' }) + expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test') + }) + + it('attendees.delete uses DELETE method', async () => { + const f = mockFetch({}, 204) + await api.attendees.delete(5) + expect(f.mock.calls[0][0]).toBe('/api/attendees/5') + expect(f.mock.calls[0][1].method).toBe('DELETE') + }) + + it('sync.pull passes since param', async () => { + const f = mockFetch({ server_time: '2026-01-01', attendees: [] }) + await api.sync.pull('2026-01-01T00:00:00Z') + expect(f.mock.calls[0][0]).toContain('since=') + }) + + it('sync.pull omits since when empty', async () => { + const f = mockFetch({ server_time: '2026-01-01', attendees: [] }) + await api.sync.pull('') + expect(f.mock.calls[0][0]).toBe('/api/sync/pull') + }) +}) diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js new file mode 100644 index 0000000..36d3c5e --- /dev/null +++ b/frontend/src/db.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { db, getLastSync, setLastSync, getSession, saveSession, clearSession } from './db.js' + +beforeEach(async () => { + await Promise.all(db.tables.map(t => t.clear())) +}) + +describe('db schema', () => { + it('has expected tables', () => { + const names = db.tables.map(t => t.name).sort() + expect(names).toEqual([ + 'attendees', 'departments', 'event', 'meta', + 'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers', + ]) + }) +}) + +describe('session', () => { + it('returns undefined when no session', async () => { + const s = await getSession() + expect(s).toBeUndefined() + }) + + it('saves and retrieves session', async () => { + await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' }) + const s = await getSession() + expect(s.token).toBe('tok123') + expect(s.user.username).toBe('admin') + }) + + it('clears session and meta', async () => { + await saveSession('tok123', { id: 1 }) + await setLastSync('2026-01-01T00:00:00Z') + await clearSession() + expect(await getSession()).toBeUndefined() + expect(await getLastSync()).toBe('') + }) +}) + +describe('lastSync', () => { + it('returns empty string when unset', async () => { + expect(await getLastSync()).toBe('') + }) + + it('roundtrips a timestamp', async () => { + await setLastSync('2026-03-01T12:00:00Z') + expect(await getLastSync()).toBe('2026-03-01T12:00:00Z') + }) +}) diff --git a/frontend/src/sync.js b/frontend/src/sync.js index f30c80a..05b16c0 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -40,6 +40,9 @@ export async function syncPull() { } if (data.volunteer_shifts?.length) { await db.volunteer_shifts.bulkPut(data.volunteer_shifts) + const deleted = data.volunteer_shifts.filter(vs => vs.deleted_at) + .map(vs => [vs.volunteer_id, vs.shift_id]) + if (deleted.length) await db.volunteer_shifts.bulkDelete(deleted) } } ) @@ -65,27 +68,30 @@ export function startSSE(onEvent) { sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`) - sseSource.onmessage = (e) => { + sseSource.onmessage = async (e) => { try { const payload = JSON.parse(e.data) if (payload.event === 'checkin') { - // Apply check-in to local Dexie immediately if (payload.data?.type === 'attendee' && payload.data?.attendee) { - db.attendees.put(payload.data.attendee) + await db.attendees.put(payload.data.attendee) } if (payload.data?.type === 'volunteer' && payload.data?.volunteer) { - db.volunteers.put(payload.data.volunteer) + await db.volunteers.put(payload.data.volunteer) } onEvent?.(payload) } - } catch {} + } catch (err) { + console.warn('SSE message error:', err.message) + } } sseSource.onerror = () => { sseSource?.close() sseSource = null - // Reconnect after 5s - setTimeout(connect, 5000) + setTimeout(() => { + connect() + syncPull() + }, 5000) } }) } diff --git a/frontend/src/sync.test.js b/frontend/src/sync.test.js new file mode 100644 index 0000000..4f69343 --- /dev/null +++ b/frontend/src/sync.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { db, getLastSync, setLastSync } from './db.js' + +beforeEach(async () => { + await Promise.all(db.tables.map(t => t.clear())) + vi.restoreAllMocks() +}) + +function mockFetch(body = {}, status = 200) { + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: () => Promise.resolve(body), + }) + ) +} + +describe('syncPull', () => { + it('writes attendees to Dexie', async () => { + mockFetch({ + server_time: '2026-03-01T12:00:00Z', + attendees: [{ id: 1, name: 'Alice' }], + departments: [], + volunteers: [], + shifts: [], + volunteer_shifts: [], + }) + // Import fresh to reset syncing guard + const { syncPull } = await import('./sync.js') + await syncPull() + + const a = await db.attendees.get(1) + expect(a.name).toBe('Alice') + expect(await getLastSync()).toBe('2026-03-01T12:00:00Z') + }) + + it('deletes soft-deleted attendees from Dexie', async () => { + await db.attendees.put({ id: 1, name: 'Alice' }) + + mockFetch({ + server_time: '2026-03-01T13:00:00Z', + attendees: [{ id: 1, name: 'Alice', deleted_at: '2026-03-01T12:30:00Z' }], + departments: [], + volunteers: [], + shifts: [], + volunteer_shifts: [], + }) + const { syncPull } = await import('./sync.js') + await syncPull() + + const a = await db.attendees.get(1) + expect(a).toBeUndefined() + }) + + it('deletes soft-deleted volunteer_shifts from Dexie', async () => { + await db.volunteer_shifts.put({ volunteer_id: 1, shift_id: 2 }) + + mockFetch({ + server_time: '2026-03-01T13:00:00Z', + attendees: [], + departments: [], + volunteers: [], + shifts: [], + volunteer_shifts: [{ volunteer_id: 1, shift_id: 2, deleted_at: '2026-03-01T12:30:00Z' }], + }) + const { syncPull } = await import('./sync.js') + await syncPull() + + const vs = await db.volunteer_shifts.get([1, 2]) + expect(vs).toBeUndefined() + }) + + it('sets lastSync timestamp', async () => { + mockFetch({ + server_time: '2026-03-02T00:00:00Z', + attendees: [], + departments: [], + volunteers: [], + shifts: [], + volunteer_shifts: [], + }) + const { syncPull } = await import('./sync.js') + await syncPull() + expect(await getLastSync()).toBe('2026-03-02T00:00:00Z') + }) +}) diff --git a/frontend/src/test-setup.js b/frontend/src/test-setup.js new file mode 100644 index 0000000..d6e9324 --- /dev/null +++ b/frontend/src/test-setup.js @@ -0,0 +1 @@ +import 'fake-indexeddb/auto' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 1f9d6aa..0f71087 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -12,4 +12,8 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.js'], + }, }) diff --git a/go.mod b/go.mod index fef0a1d..a1c34fd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module turnpike -go 1.24 +go 1.24.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/handle_attendees_test.go b/handle_attendees_test.go new file mode 100644 index 0000000..3d68911 --- /dev/null +++ b/handle_attendees_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAttendeesListCreateDelete(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // Create + req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Alice"}, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String()) + } + created := parseJSON(t, w) + id := created["id"].(float64) + + // List + req = testAuthRequest("GET", "/api/attendees", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list: status = %d", w.Code) + } + list := parseJSON(t, w) + attendees := list["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("list: got %d, want 1", len(attendees)) + } + + // Delete + req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("delete: status = %d", w.Code) + } + + // List again — should be empty + req = testAuthRequest("GET", "/api/attendees", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + list = parseJSON(t, w) + if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 { + t.Errorf("after delete: got %d, want 0", len(a2)) + } +} + +func TestCheckInAttendeeHandler(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Bob"}) + app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + + // Check in 1 + req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + attendee := result["attendee"].(map[string]any) + if attendee["checked_in_count"] != float64(1) { + t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"]) + } +} + +func TestGateRoleCanCheckIn(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Charlie"}) + + req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("gate checkin: status = %d", w.Code) + } +} + +func TestGateRoleCannotDelete(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Charlie"}) + + req := testAuthRequest("DELETE", "/api/attendees/1", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("gate delete: status = %d, want 403", w.Code) + } +} diff --git a/handle_import_test.go b/handle_import_test.go new file mode 100644 index 0000000..32c443d --- /dev/null +++ b/handle_import_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +func postCSV(t *testing.T, mux *http.ServeMux, token, csv string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, _ := writer.CreateFormFile("csv", "attendees.csv") + io.WriteString(part, csv) + writer.Close() + + req := httptest.NewRequest("POST", "/api/import", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w +} + +func TestImportCrowdWorkFormat(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "Patron Name,Patron Email,Order Number,Tier Name\nAlice,alice@test.com,ORD-1,GA\nBob,bob@test.com,ORD-2,VIP\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["inserted"] != float64(2) { + t.Errorf("inserted = %v, want 2", result["inserted"]) + } +} + +func TestImportGenericFormat(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "name,email,ticket_id,ticket_type,note\nAlice,alice@test.com,T1,GA,VIP guest\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v", result["inserted"]) + } +} + +func TestImportPartySizeDedup(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // 3 rows same name+order = 1 record, party_size=3 + csv := "Patron Name,Patron Email,Order Number,Tier Name\nAlice,alice@test.com,ORD-1,GA\nAlice,alice@test.com,ORD-1,GA\nAlice,alice@test.com,ORD-1,GA\n" + w := postCSV(t, mux, token, csv) + + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v, want 1", result["inserted"]) + } + if result["grouped"] != float64(2) { + t.Errorf("grouped = %v, want 2", result["grouped"]) + } + + attendees, _ := app.listAttendees("", "", "") + if len(attendees) != 1 { + t.Fatalf("attendee count = %d, want 1", len(attendees)) + } + if attendees[0].PartySize != 3 { + t.Errorf("party_size = %d, want 3", attendees[0].PartySize) + } +} + +func TestImportReimportSkips(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "name\nAlice\nBob\n" + postCSV(t, mux, token, csv) + + // Re-import same data + w := postCSV(t, mux, token, csv) + result := parseJSON(t, w) + if result["inserted"] != float64(0) { + t.Errorf("re-import inserted = %v, want 0", result["inserted"]) + } + if result["skipped"] != float64(2) { + t.Errorf("skipped = %v, want 2", result["skipped"]) + } +} + +func TestImportMissingNameColumn(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + csv := "email,phone\nalice@test.com,555-1234\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestImportBOM(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + // BOM-encoded CSV + csv := "\xef\xbb\xbfname,email\nAlice,alice@test.com\n" + w := postCSV(t, mux, token, csv) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["inserted"] != float64(1) { + t.Errorf("inserted = %v, want 1", result["inserted"]) + } +} diff --git a/handle_kiosk.go b/handle_kiosk.go index c23b981..c782afb 100644 --- a/handle_kiosk.go +++ b/handle_kiosk.go @@ -1,6 +1,7 @@ package main import ( + "errors" "net/http" "strconv" ) @@ -87,15 +88,12 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) { writeError(w, "shift not found", http.StatusNotFound) return } - if shift.Capacity > 0 { - count, _ := app.shiftAssignedCount(shiftID) - if count >= shift.Capacity { + + if err := app.assignShiftWithCapacity(v.ID, shiftID, shift.Capacity); err != nil { + if errors.Is(err, errShiftFull) { writeError(w, "shift is full", http.StatusConflict) return } - } - - if err := app.assignShift(v.ID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go new file mode 100644 index 0000000..f5c5057 --- /dev/null +++ b/handle_kiosk_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { + t.Helper() + app := testApp(t) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + + // Create attendee with token + a, _ := app.createAttendee(Attendee{Name: "Alice", Email: "alice@test.com"}) + token, _ := app.generateUniqueToken() + app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID) + + // Create linked volunteer + app.createVolunteer(Volunteer{Name: "Alice", AttendeeID: &a.ID, DepartmentID: &deptID}) + + // Create shifts + app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2}) + app.createShift(Shift{DepartmentID: deptID, Name: "Afternoon", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00", Capacity: 1}) + + return app, mux, token +} + +func TestKioskGetValid(t *testing.T) { + _, mux, token := setupKiosk(t) + + req := httptest.NewRequest("GET", "/api/v/"+token, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["volunteer"] == nil { + t.Error("missing volunteer") + } + available := result["available"].([]any) + if len(available) != 2 { + t.Errorf("available = %d, want 2", len(available)) + } +} + +func TestKioskGetInvalidToken(t *testing.T) { + _, mux, _ := setupKiosk(t) + + req := httptest.NewRequest("GET", "/api/v/BADTOKEN", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestKioskClaimShift(t *testing.T) { + _, mux, token := setupKiosk(t) + + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("claim: status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + shifts := result["shifts"].([]any) + if len(shifts) != 1 { + t.Errorf("assigned shifts = %d, want 1", len(shifts)) + } +} + +func TestKioskClaimConflict(t *testing.T) { + app, mux, token := setupKiosk(t) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + // Create overlapping shift + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + + // Claim morning (08:00-12:00) + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("first claim: status = %d", w.Code) + } + + // Claim overlapping shift (10:00-14:00) — should get 409 + req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("conflict: status = %d, want 409\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["conflict"] != true { + t.Error("missing conflict flag") + } +} + +func TestKioskClaimForce(t *testing.T) { + app, mux, token := setupKiosk(t) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + + // Claim morning + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // Force-claim overlapping shift + req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3?force=true", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("force: status = %d, want 200", w.Code) + } +} + +func TestKioskClaimFull(t *testing.T) { + app, mux, token := setupKiosk(t) + + // Shift 2 has capacity 1. Fill it with another volunteer. + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID}) + app.assignShift(other.ID, 2) // fills the capacity-1 shift + + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Errorf("full: status = %d, want 409\nbody: %s", w.Code, w.Body.String()) + } +} + +func TestKioskUnclaim(t *testing.T) { + _, mux, token := setupKiosk(t) + + // Claim then unclaim + req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + req = httptest.NewRequest("DELETE", "/api/v/"+token+"/shifts/1", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("unclaim: status = %d", w.Code) + } + result := parseJSON(t, w) + shifts := result["shifts"].([]any) + if len(shifts) != 0 { + t.Errorf("after unclaim: shifts = %d, want 0", len(shifts)) + } +} diff --git a/handle_settings_test.go b/handle_settings_test.go new file mode 100644 index 0000000..28db093 --- /dev/null +++ b/handle_settings_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetSettings(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/settings", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + if result["smtp_host"] == nil { + t.Error("missing smtp_host key") + } +} + +func TestUpdateSettings(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + req := testAuthRequest("PUT", "/api/settings", map[string]any{ + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_password": "secret", + "base_url": "https://turnpike.example.com", + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String()) + } + result := parseJSON(t, w) + if result["smtp_host"] != "smtp.example.com" { + t.Errorf("smtp_host = %v", result["smtp_host"]) + } + if result["smtp_password"] != "***" { + t.Errorf("smtp_password = %v, want '***'", result["smtp_password"]) + } + if result["base_url"] != "https://turnpike.example.com" { + t.Errorf("base_url = %v", result["base_url"]) + } +} + +func TestSettingsNonAdminRejected(t *testing.T) { + app := testApp(t) + gate := testUserWithRole(t, app, "gateuser", "gate", []int{}) + token := testToken(t, app, gate) + mux := testMux(app) + + req := testAuthRequest("GET", "/api/settings", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} diff --git a/handle_shifts_test.go b/handle_shifts_test.go new file mode 100644 index 0000000..bc7d629 --- /dev/null +++ b/handle_shifts_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestShiftsCRUDHandler(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + + // Create + req := testAuthRequest("POST", "/api/shifts", map[string]any{ + "department_id": dept.ID, + "name": "Morning", + "day": "2026-03-15", + "start_time": "08:00", + "end_time": "12:00", + "capacity": 5, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + // List + req = testAuthRequest("GET", "/api/shifts", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("list: status = %d", w.Code) + } + + // Delete + req = testAuthRequest("DELETE", "/api/shifts/1", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("delete: status = %d", w.Code) + } +} + +func TestShiftAssignVolunteer(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + + // Assign + req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ + "volunteer_id": 1, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("assign: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + // Unassign + req = testAuthRequest("DELETE", "/api/shifts/1/volunteers/1", nil, token) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("unassign: status = %d", w.Code) + } +} + +func TestShiftAssignConflict(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"}) + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + + // Assign to first shift + app.assignShift(1, 1) + + // Try to assign to overlapping shift — should get 409 + req := testAuthRequest("POST", "/api/shifts/2/volunteers", map[string]any{ + "volunteer_id": 1, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("conflict: status = %d, want 409", w.Code) + } +} + +func TestShiftReorder(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createShift(Shift{DepartmentID: deptID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + app.createShift(Shift{DepartmentID: deptID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) + + req := testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ + {"id": 1, "position": 2}, + {"id": 2, "position": 1}, + }, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("reorder: status = %d\nbody: %s", w.Code, w.Body.String()) + } + + s1, _ := app.getShift(1) + s2, _ := app.getShift(2) + if s1.Position != 2 || s2.Position != 1 { + t.Errorf("positions: s1=%d, s2=%d, want 2,1", s1.Position, s2.Position) + } +} diff --git a/handle_sync_test.go b/handle_sync_test.go new file mode 100644 index 0000000..03c529e --- /dev/null +++ b/handle_sync_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSyncPullFull(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Alice"}) + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createVolunteer(Volunteer{Name: "Alice", DepartmentID: &deptID}) + app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + req := testAuthRequest("GET", "/api/sync/pull", nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + result := parseJSON(t, w) + + if result["server_time"] == nil { + t.Error("missing server_time") + } + attendees := result["attendees"].([]any) + if len(attendees) != 1 { + t.Errorf("attendees = %d, want 1", len(attendees)) + } + depts := result["departments"].([]any) + if len(depts) != 1 { + t.Errorf("departments = %d, want 1", len(depts)) + } +} + +func TestSyncPullIncremental(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + app.createAttendee(Attendee{Name: "Alice"}) + // Backdate Alice so she falls before the "since" cutoff + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Alice'`) + + since := "2026-01-01T12:00:00Z" + + // Bob created with default updated_at (now), which is after our since + app.createAttendee(Attendee{Name: "Bob"}) + + req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + result := parseJSON(t, w) + attendees := result["attendees"].([]any) + // Should only include Bob (created after `since`) + if len(attendees) != 1 { + t.Errorf("incremental: got %d attendees, want 1", len(attendees)) + } + if len(attendees) == 1 { + a := attendees[0].(map[string]any) + if a["name"] != "Bob" { + t.Errorf("name = %v, want Bob", a["name"]) + } + } +} + +func TestSyncPullIncludesSoftDeleted(t *testing.T) { + app := testApp(t) + admin := testAdminUser(t, app) + token := testToken(t, app, admin) + mux := testMux(app) + + a, _ := app.createAttendee(Attendee{Name: "Alice"}) + // Backdate Alice's creation so the since cutoff is between creation and deletion + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID) + + since := "2026-01-01T12:00:00Z" + + // Delete updates updated_at to now(), which is after our since + app.deleteAttendee(a.ID) + + req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var result struct { + Attendees []struct { + ID int `json:"id"` + DeletedAt *string `json:"deleted_at"` + } `json:"attendees"` + } + json.Unmarshal(w.Body.Bytes(), &result) + + if len(result.Attendees) != 1 { + t.Fatalf("got %d attendees, want 1", len(result.Attendees)) + } + if result.Attendees[0].DeletedAt == nil { + t.Error("deleted_at should be set for soft-deleted record") + } +} diff --git a/testutil_test.go b/testutil_test.go new file mode 100644 index 0000000..8f58833 --- /dev/null +++ b/testutil_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func testApp(t *testing.T) *App { + t.Helper() + db, err := initDB(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + // Ensure config table exists (normally created by getOrCreateSecret) + db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) + return &App{ + db: db, + secret: "test-secret", + tokenExpiry: 24, + broker: newBroker(), + } +} + +func testAdminUser(t *testing.T, app *App) *User { + t.Helper() + hash, _ := hashPassword("admin123") + u, err := app.createUser("admin", hash, "admin", []int{}) + if err != nil { + t.Fatal(err) + } + return u +} + +func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { + t.Helper() + hash, _ := hashPassword(username + "123") + u, err := app.createUser(username, hash, role, deptIDs) + if err != nil { + t.Fatal(err) + } + return u +} + +func testToken(t *testing.T, app *App, user *User) string { + t.Helper() + token, err := app.signToken(user) + if err != nil { + t.Fatal(err) + } + return token +} + +func testMux(app *App) *http.ServeMux { + mux := http.NewServeMux() + app.registerRoutes(mux) + return mux +} + +func testRequest(method, path string, body any) *http.Request { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + return req +} + +func testAuthRequest(method, path string, body any, token string) *http.Request { + req := testRequest(method, path, body) + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +func itoa(i int) string { + return fmt.Sprintf("%d", i) +} + +func parseJSON(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("parse JSON: %v\nbody: %s", err, w.Body.String()) + } + return result +} From 0d940749b1098d284150a40bfc7677f29e8f4617 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 13:38:31 -0600 Subject: [PATCH 04/16] Added build ID to footer. Added check for client-server mismatch. --- Makefile | 4 +-- frontend/.gitignore | 1 + frontend/src/App.svelte | 65 +++++++++++++++++++++++++++++++++++++++++ frontend/vite.config.js | 3 ++ main.go | 6 ++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 43067d4..e6882e6 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ .PHONY: build frontend-build dev clean test build: frontend-build - CGO_ENABLED=0 go build -o turnpike . + CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . frontend-build: - cd frontend && npm ci && npm run build + cd frontend && npm ci && BUILD_ID=$$(git rev-parse --short HEAD) npm run build dev: @echo "Run in two terminals:" diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..7e445ac 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.vite dist dist-ssr *.local diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a2cdf76..04aa9bd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,14 +17,27 @@ import Nav from './components/Nav.svelte' import SyncStatus from './components/SyncStatus.svelte' + const clientBuild = __BUILD_ID__ + let session = $state(null) let loading = $state(true) let route = $state(window.location.hash || '#/') + let updateAvailable = $state(false) // Check if this is a kiosk token URL before doing anything else const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '') + async function checkVersion() { + try { + const res = await fetch('/api/version') + const { build } = await res.json() + if (build && build !== clientBuild) updateAvailable = true + } catch {} + } + onMount(async () => { + checkVersion() + // Kiosk pages don't need auth if (kioskToken) { loading = false @@ -40,6 +53,9 @@ window.addEventListener('hashchange', () => { route = window.location.hash || '#/' }) + + // Periodically check for updates + setInterval(checkVersion, 60000) }) function onLogin(s) { @@ -58,6 +74,13 @@ const role = $derived(session?.user?.role ?? '') +{#if updateAvailable} +
+ A new version is available. + +
+{/if} + {#if loading} {:else if kioskToken} @@ -100,3 +123,45 @@ {/if} + +
{clientBuild}
+ + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0f71087..69405ba 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' export default defineConfig({ plugins: [svelte()], + define: { + __BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'), + }, server: { proxy: { '/api': 'http://localhost:8180', diff --git a/main.go b/main.go index 2fabeb6..aca8086 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( "os" ) +var buildID = "dev" + //go:embed frontend/dist var frontendFS embed.FS @@ -145,6 +147,10 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) + mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]string{"build": buildID}) + }) + // Kiosk — authenticated by volunteer token, no JWT required. mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet) mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim) From 9a367bb78b4f5d27866a3dc046ff37aec0d8385c Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 16:09:43 -0600 Subject: [PATCH 05/16] Added mobile hamburger. --- frontend/src/App.svelte | 14 ++++++++++++- frontend/src/app.css | 33 +++++++++++++++++++++++++++++- frontend/src/components/Nav.svelte | 4 ++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 04aa9bd..566912a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,6 +23,7 @@ let loading = $state(true) let route = $state(window.location.hash || '#/') let updateAvailable = $state(false) + let mobileNavOpen = $state(false) // Check if this is a kiosk token URL before doing anything else const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '') @@ -52,6 +53,7 @@ } window.addEventListener('hashchange', () => { route = window.location.hash || '#/' + mobileNavOpen = false }) // Periodically check for updates @@ -92,8 +94,18 @@ {:else}
-
{/if} + +
{clientBuild}
+ + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0f71087..69405ba 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' export default defineConfig({ plugins: [svelte()], + define: { + __BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'), + }, server: { proxy: { '/api': 'http://localhost:8180', diff --git a/main.go b/main.go index 2fabeb6..aca8086 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( "os" ) +var buildID = "dev" + //go:embed frontend/dist var frontendFS embed.FS @@ -145,6 +147,10 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) + mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]string{"build": buildID}) + }) + // Kiosk — authenticated by volunteer token, no JWT required. mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet) mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim) From 4461adfa7a306864c55f1841251efca9983978b4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 16:09:43 -0600 Subject: [PATCH 13/16] Added mobile hamburger. --- frontend/src/App.svelte | 14 ++++++++++++- frontend/src/app.css | 33 +++++++++++++++++++++++++++++- frontend/src/components/Nav.svelte | 4 ++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 04aa9bd..566912a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,6 +23,7 @@ let loading = $state(true) let route = $state(window.location.hash || '#/') let updateAvailable = $state(false) + let mobileNavOpen = $state(false) // Check if this is a kiosk token URL before doing anything else const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '') @@ -52,6 +53,7 @@ } window.addEventListener('hashchange', () => { route = window.location.hash || '#/' + mobileNavOpen = false }) // Periodically check for updates @@ -92,8 +94,18 @@ {:else}
-