From 9d0fa1f0af87112937fbc6e9eb456771b2c54f17 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 11:39:15 -0600 Subject: [PATCH 01/54] 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 f9c4facad63be20686254f9c40a7b184791c5ef9 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 12:50:24 -0600 Subject: [PATCH 02/54] 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..0d453bb --- /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: "Titania", Email: "titania@test.com", TicketType: "GA"}) + if err != nil { + t.Fatal(err) + } + if a.ID == 0 || a.Name != "Titania" { + 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 != "titania@test.com" { + t.Errorf("get: email = %q", got.Email) + } + + got.Name = "Titania Fairweather" + if err := app.updateAttendee(*got); err != nil { + t.Fatal(err) + } + got2, _ := app.getAttendee(a.ID) + if got2.Name != "Titania Fairweather" { + 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: "Oberon", TicketID: "ORD-100"}) + + merged, err := app.incrementPartySize("Oberon", "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("Oberon", "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: "Puck"}) + // 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: "Helena", 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: "Hermia", 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: "Lysander", 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..25e0dd9 --- /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: 'Titania' }) + const result = await apiJSON('/api/test') + expect(result.name).toBe('Titania') + }) + + 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..2c8915e --- /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: 'Titania' }], + 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('Titania') + expect(await getLastSync()).toBe('2026-03-01T12:00:00Z') + }) + + it('deletes soft-deleted attendees from Dexie', async () => { + await db.attendees.put({ id: 1, name: 'Titania' }) + + mockFetch({ + server_time: '2026-03-01T13:00:00Z', + attendees: [{ id: 1, name: 'Titania', 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..827a4b2 --- /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": "Titania"}, 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: "Oberon"}) + 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: "Puck"}) + + 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: "Puck"}) + + 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..a062c53 --- /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\nTitania,titania@test.com,ORD-1,GA\nOberon,oberon@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\nTitania,titania@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\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@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\nTitania\nOberon\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\ntitania@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\nTitania,titania@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..0eb252b --- /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: "Titania", Email: "titania@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: "Titania", 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..8c629b1 --- /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: "Titania", 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: "Titania", 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..c5d759a --- /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: "Titania"}) + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + app.createVolunteer(Volunteer{Name: "Titania", 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: "Titania"}) + // Backdate Titania so she falls before the "since" cutoff + app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`) + + since := "2026-01-01T12:00:00Z" + + // Oberon created with default updated_at (now), which is after our since + app.createAttendee(Attendee{Name: "Oberon"}) + + 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 Oberon (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"] != "Oberon" { + t.Errorf("name = %v, want Oberon", 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: "Titania"}) + // Backdate Titania'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 ec5b5192daef9a934427a77094606aee40d7bd8b Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 13:38:31 -0600 Subject: [PATCH 03/54] 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 4461adfa7a306864c55f1841251efca9983978b4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 3 Mar 2026 16:09:43 -0600 Subject: [PATCH 04/54] 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}
-
diff --git a/frontend/src/sync.js b/frontend/src/sync.js index e8f2d1a..fa3b641 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -12,17 +12,11 @@ export async function syncPull() { const data = await api.sync.pull(since) await db.transaction('rw', - [db.event, db.attendees, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + [db.event, db.participants, db.tickets, 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.participants?.length) { await db.participants.bulkPut(data.participants) const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id) @@ -82,9 +76,6 @@ export function startSSE(onEvent) { try { const payload = JSON.parse(e.data) if (payload.event === 'checkin') { - if (payload.data?.type === 'attendee' && payload.data?.attendee) { - await db.attendees.put(payload.data.attendee) - } if (payload.data?.type === 'ticket' && payload.data?.ticket) { await db.tickets.put(payload.data.ticket) } diff --git a/frontend/src/sync.test.js b/frontend/src/sync.test.js index 2c8915e..21c213e 100644 --- a/frontend/src/sync.test.js +++ b/frontend/src/sync.test.js @@ -18,30 +18,31 @@ function mockFetch(body = {}, status = 200) { } describe('syncPull', () => { - it('writes attendees to Dexie', async () => { + it('writes participants to Dexie', async () => { mockFetch({ server_time: '2026-03-01T12:00:00Z', - attendees: [{ id: 1, name: 'Titania' }], + participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }], + tickets: [], 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('Titania') + const p = await db.participants.get(1) + expect(p.preferred_name).toBe('Titania') expect(await getLastSync()).toBe('2026-03-01T12:00:00Z') }) - it('deletes soft-deleted attendees from Dexie', async () => { - await db.attendees.put({ id: 1, name: 'Titania' }) + it('deletes soft-deleted participants from Dexie', async () => { + await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }) mockFetch({ server_time: '2026-03-01T13:00:00Z', - attendees: [{ id: 1, name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }], + participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }], + tickets: [], departments: [], volunteers: [], shifts: [], @@ -50,8 +51,8 @@ describe('syncPull', () => { const { syncPull } = await import('./sync.js') await syncPull() - const a = await db.attendees.get(1) - expect(a).toBeUndefined() + const p = await db.participants.get(1) + expect(p).toBeUndefined() }) it('deletes soft-deleted volunteer_shifts from Dexie', async () => { @@ -59,7 +60,8 @@ describe('syncPull', () => { mockFetch({ server_time: '2026-03-01T13:00:00Z', - attendees: [], + participants: [], + tickets: [], departments: [], volunteers: [], shifts: [], @@ -75,7 +77,8 @@ describe('syncPull', () => { it('sets lastSync timestamp', async () => { mockFetch({ server_time: '2026-03-02T00:00:00Z', - attendees: [], + participants: [], + tickets: [], departments: [], volunteers: [], shifts: [], diff --git a/handle_attendees.go b/handle_attendees.go deleted file mode 100644 index 0ee5628..0000000 --- a/handle_attendees.go +++ /dev/null @@ -1,177 +0,0 @@ -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 { - // Try to find volunteer via participant_id first (new model), fall back to attendee_id (legacy). - var v *Volunteer - if a != nil { - p, _ := app.getParticipantByEmail(a.Email) - if p != nil { - v, _ = app.getVolunteerByParticipantID(p.ID) - } - } - if v == nil { - 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_attendees_test.go b/handle_attendees_test.go index ff2a196..c5e6adb 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -6,14 +6,14 @@ import ( "testing" ) -func TestAttendeesListCreateDelete(t *testing.T) { +func TestParticipantsListCreateDelete(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": "Titania"}, token) + req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusCreated { @@ -23,20 +23,20 @@ func TestAttendeesListCreateDelete(t *testing.T) { id := created["id"].(float64) // List - req = testAuthRequest("GET", "/api/attendees", nil, token) + req = testAuthRequest("GET", "/api/participants", 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)) + participants := list["participants"].([]any) + if len(participants) != 1 { + t.Errorf("list: got %d, want 1", len(participants)) } // Delete - req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token) + req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token) w = httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusNoContent { @@ -44,66 +44,66 @@ func TestAttendeesListCreateDelete(t *testing.T) { } // List again — should be empty - req = testAuthRequest("GET", "/api/attendees", nil, token) + req = testAuthRequest("GET", "/api/participants", 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)) + if ps, ok := list["participants"].([]any); ok && len(ps) != 0 { + t.Errorf("after delete: got %d, want 0", len(ps)) } } -func TestCheckInAttendeeHandler(t *testing.T) { +func TestCheckInTicketHandler(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - app.createAttendee(Attendee{Name: "Oberon"}) - app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`) + p, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) + tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Oberon", Source: "manual"}) - // Check in 1 - req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token) + req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, 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"]) + ticket := result["ticket"].(map[string]any) + if ticket["checked_in_at"] == nil { + t.Error("checked_in_at should be set after check-in") } } -func TestGateRoleCanCheckIn(t *testing.T) { +func TestGatekeeperRoleCanCheckIn(t *testing.T) { app := testApp(t) gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) - app.createAttendee(Attendee{Name: "Puck"}) + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"}) + tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Puck", Source: "manual"}) - req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token) + req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("gate checkin: status = %d", w.Code) + t.Errorf("gatekeeper checkin: status = %d", w.Code) } } -func TestGateRoleCannotDelete(t *testing.T) { +func TestGatekeeperRoleCannotDelete(t *testing.T) { app := testApp(t) gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) - app.createAttendee(Attendee{Name: "Puck"}) + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"}) - req := testAuthRequest("DELETE", "/api/attendees/1", nil, token) + req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusForbidden { - t.Errorf("gate delete: status = %d, want 403", w.Code) + t.Errorf("gatekeeper delete: status = %d, want 403", w.Code) } } diff --git a/handle_settings.go b/handle_settings.go index f812134..d4ed01c 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -79,17 +79,6 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { app.handleGetSettings(w, r) } -func (app *App) handleResetAttendees(w http.ResponseWriter, r *http.Request) { - ts := now() - result, err := app.db.Exec(`UPDATE attendees SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - n, _ := result.RowsAffected() - writeJSON(w, map[string]any{"deleted": n}) -} - func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) { ts := now() result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts) diff --git a/handle_settings_test.go b/handle_settings_test.go index c588c88..cbc53fb 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -55,17 +55,19 @@ func TestUpdateSettings(t *testing.T) { } } -func TestResetAttendees(t *testing.T) { +func TestResetTickets(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) token := testToken(t, app, admin) mux := testMux(app) - app.createAttendee(Attendee{Name: "Titania", Email: "titania@example.com"}) - app.createAttendee(Attendee{Name: "Oberon", Email: "oberon@example.com"}) + p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + p2, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) + app.createTicket(Ticket{ParticipantID: &p1.ID, Name: "Titania", Source: "manual"}) + app.createTicket(Ticket{ParticipantID: &p2.ID, Name: "Oberon", Source: "manual"}) w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token)) + mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token)) if w.Code != 200 { t.Fatalf("status = %d: %s", w.Code, w.Body.String()) @@ -75,20 +77,20 @@ func TestResetAttendees(t *testing.T) { t.Fatalf("deleted = %v, want 2", result["deleted"]) } - attendees, _ := app.listAttendees("", "", "") - if len(attendees) != 0 { - t.Fatalf("attendees remaining = %d, want 0", len(attendees)) + tickets, _ := app.listTickets(nil, "") + if len(tickets) != 0 { + t.Fatalf("tickets remaining = %d, want 0", len(tickets)) } } -func TestResetAttendeesRequiresAdmin(t *testing.T) { +func TestResetTicketsRequiresAdmin(t *testing.T) { app := testApp(t) gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) token := testToken(t, app, gate) mux := testMux(app) w := httptest.NewRecorder() - mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-attendees", nil, token)) + mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token)) if w.Code != 403 { t.Fatalf("status = %d, want 403", w.Code) diff --git a/handle_sync.go b/handle_sync.go index a11d414..f2171ce 100644 --- a/handle_sync.go +++ b/handle_sync.go @@ -12,7 +12,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { since := r.URL.Query().Get("since") event, _ := app.getEvent() - attendees, _ := app.attendeesSince(since) participants, _ := app.listParticipants("", since) tickets, _ := app.listTickets(nil, since) departments, _ := app.listDepartments(since) @@ -20,9 +19,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { shifts, _ := app.listShifts(nil, "", since) volunteerShifts, _ := app.listVolunteerShifts(since) - if attendees == nil { - attendees = []Attendee{} - } if participants == nil { participants = []Participant{} } @@ -45,7 +41,6 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{ "server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"), "event": event, - "attendees": attendees, "participants": participants, "tickets": tickets, "departments": departments, diff --git a/handle_sync_test.go b/handle_sync_test.go index c5d759a..e4aa2af 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -13,7 +13,7 @@ func TestSyncPullFull(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - app.createAttendee(Attendee{Name: "Titania"}) + app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) @@ -31,9 +31,9 @@ func TestSyncPullFull(t *testing.T) { 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)) + participants := result["participants"].([]any) + if len(participants) != 1 { + t.Errorf("participants = %d, want 1", len(participants)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,29 +47,29 @@ func TestSyncPullIncremental(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - app.createAttendee(Attendee{Name: "Titania"}) + p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) // Backdate Titania so she falls before the "since" cutoff - app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`) + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID) since := "2026-01-01T12:00:00Z" // Oberon created with default updated_at (now), which is after our since - app.createAttendee(Attendee{Name: "Oberon"}) + app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) 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) + participants := result["participants"].([]any) // Should only include Oberon (created after `since`) - if len(attendees) != 1 { - t.Errorf("incremental: got %d attendees, want 1", len(attendees)) + if len(participants) != 1 { + t.Errorf("incremental: got %d participants, want 1", len(participants)) } - if len(attendees) == 1 { - a := attendees[0].(map[string]any) - if a["name"] != "Oberon" { - t.Errorf("name = %v, want Oberon", a["name"]) + if len(participants) == 1 { + p := participants[0].(map[string]any) + if p["preferred_name"] != "Oberon" { + t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"]) } } } @@ -80,31 +80,31 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - a, _ := app.createAttendee(Attendee{Name: "Titania"}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) // Backdate Titania'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) + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID) since := "2026-01-01T12:00:00Z" // Delete updates updated_at to now(), which is after our since - app.deleteAttendee(a.ID) + app.deleteParticipant(p.ID) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() mux.ServeHTTP(w, req) var result struct { - Attendees []struct { + Participants []struct { ID int `json:"id"` DeletedAt *string `json:"deleted_at"` - } `json:"attendees"` + } `json:"participants"` } json.Unmarshal(w.Body.Bytes(), &result) - if len(result.Attendees) != 1 { - t.Fatalf("got %d attendees, want 1", len(result.Attendees)) + if len(result.Participants) != 1 { + t.Fatalf("got %d participants, want 1", len(result.Participants)) } - if result.Attendees[0].DeletedAt == nil { + if result.Participants[0].DeletedAt == nil { t.Error("deleted_at should be set for soft-deleted record") } } diff --git a/main.go b/main.go index ee2db53..2eedcd0 100644 --- a/main.go +++ b/main.go @@ -99,18 +99,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/event", auth(app.handleGetEvent)) mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin", "ticketing")) - mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gatekeeper")) - 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", "gatekeeper")) - 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", "gatekeeper")) - mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing")) - mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) @@ -157,7 +145,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin", "ticketing")) mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-attendees", auth(app.handleResetAttendees, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing")) mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing")) From e7b25ea0c6044103b4f0b0c178f3cd7a996e4832 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 20:52:12 -0600 Subject: [PATCH 18/54] Updated Dashboard and clarified default states. --- frontend/src/pages/Dashboard.svelte | 177 ++++++++++++++++++++---- frontend/src/pages/Departments.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 4 +- frontend/src/pages/Users.svelte | 14 +- frontend/src/pages/Volunteers.svelte | 10 +- 5 files changed, 172 insertions(+), 35 deletions(-) diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index c1ae495..9a69d10 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,13 +4,54 @@ let { session } = $props() - const attendees = liveQuery(() => db.attendees.toArray()) - const event = liveQuery(() => db.event.get(1)) + const role = $derived(session?.user?.role ?? '') + const myDeptIDs = $derived(session?.user?.department_ids ?? []) + const isTicketing = $derived(['admin', 'ticketing'].includes(role)) + const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) + const isColead = $derived(role === 'colead') - const total = $derived(($attendees ?? []).length) - const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length) - const remaining = $derived(total - checkedIn) - const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0) + const event = liveQuery(() => db.event.get(1)) + const allTickets = liveQuery(() => db.tickets.toArray()) + const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray()) + const allShifts = liveQuery(() => db.shifts.filter(s => !s.deleted_at).toArray()) + const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray()) + const allVS = liveQuery(() => db.volunteer_shifts.toArray()) + + // Ticket stats + const tickets = $derived($allTickets ?? []) + const ticketTotal = $derived(tickets.length) + const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length) + const ticketRemaining = $derived(ticketTotal - ticketCheckedIn) + const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0) + + // Volunteer stats (scoped for colead) + const volunteers = $derived.by(() => { + const vols = $allVolunteers ?? [] + if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id)) + return vols + }) + const volTotal = $derived(volunteers.length) + const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length) + const volLeads = $derived(volunteers.filter(v => v.is_lead).length) + + // Shift stats (scoped for colead) + const shifts = $derived.by(() => { + const all = $allShifts ?? [] + if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id)) + return all + }) + const shiftTotal = $derived(shifts.length) + const shiftsFilled = $derived.by(() => { + const vs = $allVS ?? [] + return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length + }) + const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0) + + // Department names for colead header + const myDeptNames = $derived.by(() => { + const depts = $allDepts ?? [] + return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean) + })
@@ -28,35 +69,113 @@

{/if} -
-
-
Total
-
{total}
-
-
-
Checked in
-
{checkedIn}
-
-
-
Remaining
-
{remaining}
-
-
-
Progress
-
{pct}%
-
-
+ {#if isColead && myDeptNames.length > 0} +

+ Your department{myDeptNames.length > 1 ? 's' : ''}: + {myDeptNames.join(', ')} +

+ {/if} - {#if total > 0} -
-
-
+ + {#if isTicketing} +

Ticket Check-in

+
+
+
Total tickets
+
{ticketTotal}
+
+
+
Checked in
+
{ticketCheckedIn}
+
+
+
Remaining
+
{ticketRemaining}
+
+
+
Progress
+
{ticketPct}%
+
+
+ + {#if ticketTotal > 0} +
+
+
+ {/if} + {/if} + + + {#if isStaffing || isColead} +

{isColead ? 'My Volunteers' : 'Volunteers'}

+
+
+
Total
+
{volTotal}
+
+
+
Checked in
+
{volCheckedIn}
+
+
+
Leads
+
{volLeads}
{/if} -

+ + {#if isStaffing || isColead} +

{isColead ? 'My Shifts' : 'Shift Coverage'}

+
+
+
Total shifts
+
{shiftTotal}
+
+
+
With volunteers
+
{shiftsFilled}
+
+
+
Fill rate
+
{shiftFillPct}%
+
+
+ {/if} + + + {#if isTicketing} + + {:else if isStaffing || isColead} + + {/if} + +

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

+ + diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index b50fde4..b7fd627 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -127,7 +127,7 @@ {#if ($allDepts ?? []).length === 0}
No departments yet -

Add departments to organize your volunteer teams.

+

Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.

{:else}
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 5d9d265..2d0b555 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -315,8 +315,8 @@ {#if ($allShifts ?? []).length === 0 && !showAdd}
- No shifts yet -

Add shifts to schedule your volunteers.

+ No shifts scheduled yet +

Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.

{:else} {#each board as { dept, days }} diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index 237617b..c683fb9 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -117,7 +117,7 @@ } function roleLabel(r) { - return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r + return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -129,6 +129,15 @@
+

+ Roles: + admin — full access · + ticketing — participants, tickets, import · + staffing — volunteers, shifts, departments · + colead — manage assigned departments only · + gatekeeper — check-in only +

+ {#if loadError}
{loadError}
{/if} @@ -187,7 +196,8 @@
Loading…
{:else if users.length === 0}
- No users yet + No additional users +

The admin account was created at setup. Add users above to delegate access.

{:else}
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 39bc98b..e5df71a 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -20,6 +20,14 @@ const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const myDeptIDs = $derived(session?.user?.department_ids ?? []) + + // Auto-filter coleads to their department on mount + $effect(() => { + if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) { + filterDept = String(myDeptIDs[0]) + } + }) const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray() @@ -177,7 +185,7 @@ {#if ($allVolunteers ?? []).length === 0}
No volunteers yet -

Add volunteers manually.

+

Add volunteers manually above, or enable public signup in Settings.

{:else}
From ecfbfcd53ec0d96c0c077d0409e6b25ea27c9cb6 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 21:06:00 -0600 Subject: [PATCH 19/54] Used preferred name in volunteer signup. --- handle_signup.go | 16 ++++--- handle_signup_test.go | 102 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/handle_signup.go b/handle_signup.go index 31c796b..2373e9c 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -69,11 +69,7 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { } // Find or create participant by email. - name := body.PreferredName - if body.TicketName != "" { - name = body.TicketName - } - participant, _, err := app.upsertParticipant(body.Email, name) + participant, _, err := app.upsertParticipant(body.Email, body.PreferredName) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return @@ -166,8 +162,13 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { if len(tickets) > 0 { ticketID = tickets[0].ID } else { + tkName := vol.TicketName + if tkName == "" { + tkName = vol.PreferredName + } stub, err := app.createTicket(Ticket{ ParticipantID: vol.ParticipantID, + Name: tkName, Source: "manual", }) if err == nil { @@ -228,8 +229,13 @@ func (app *App) openShiftSignups() { if len(tickets) > 0 { ticketID = tickets[0].ID } else { + tkName := v.TicketName + if tkName == "" { + tkName = v.PreferredName + } stub, err := app.createTicket(Ticket{ ParticipantID: v.ParticipantID, + Name: tkName, Source: "manual", }) if err != nil { diff --git a/handle_signup_test.go b/handle_signup_test.go index a9bdab0..86bcfe5 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -315,6 +315,108 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { } } +func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{ + "preferred_name": "Titania", + "ticket_name": "Titania Fairweather", + "email": "titania@example.com", + })) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + vol, _ := app.getVolunteerByEmail("titania@example.com") + if vol == nil || vol.ParticipantID == nil { + t.Fatal("volunteer/participant not created") + } + p, _ := app.getParticipant(*vol.ParticipantID) + if p == nil { + t.Fatal("participant not found") + } + if p.PreferredName != "Titania" { + t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania") + } + if vol.TicketName != "Titania Fairweather" { + t.Errorf("vol.TicketName = %q, want %q", vol.TicketName, "Titania Fairweather") + } +} + +func TestConfirmEmailStubTicketHasName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) + app.baseURL = "https://example.com" + + // Volunteer with a ticket_name but no pre-existing ticket + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + TicketName: "Titania Fairweather", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + result := parseJSON(t, w) + if result["status"] != "confirmed" { + t.Fatalf("expected confirmed, got %v", result["status"]) + } + + // Stub ticket should have been created with TicketName as its name + tickets, _ := app.listTickets(&participant.ID, "") + if len(tickets) == 0 { + t.Fatal("expected stub ticket to be created") + } + if tickets[0].Name != "Titania Fairweather" { + t.Errorf("stub ticket name = %q, want %q", tickets[0].Name, "Titania Fairweather") + } +} + +func TestConfirmEmailStubTicketFallsBackToPreferredName(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) + app.baseURL = "https://example.com" + + // Volunteer with no ticket_name — stub should use preferred_name + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + token := "abc123def456" + app.createVolunteer(Volunteer{ + Name: "Titania", + PreferredName: "Titania", + Email: "titania@example.com", + ParticipantID: &participant.ID, + ConfirmationToken: &token, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + tickets, _ := app.listTickets(&participant.ID, "") + if len(tickets) == 0 { + t.Fatal("expected stub ticket to be created") + } + if tickets[0].Name != "Titania" { + t.Errorf("stub ticket name = %q, want %q (preferred_name fallback)", tickets[0].Name, "Titania") + } +} + func TestToggleShiftSignups(t *testing.T) { app := testApp(t) mux := testMux(app) From 940cf29d04ee67b53c663026b467bb33a982a3c8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 22:25:31 -0600 Subject: [PATCH 20/54] Added ability to set colead. --- frontend/src/pages/Volunteers.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e5df71a..1bf13c4 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -87,6 +87,15 @@ } } + async function toggleLead(v) { + try { + const updated = await api.volunteers.update(v.id, { ...v, is_lead: !v.is_lead }) + await db.volunteers.put(updated) + } catch (err) { + error = err.message + } + } + async function deleteVolunteer(v) { if (!confirm(`Delete volunteer "${v.name}"?`)) return try { @@ -243,6 +252,10 @@ {#if canManage} + {/if} From 6eb72c50918cba2616e2d69eefb930da3c2c4abc Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 22:32:10 -0600 Subject: [PATCH 21/54] Clarified Co-Lead badging. --- frontend/src/pages/Kiosk.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 11 +++++++++++ frontend/src/pages/Volunteers.svelte | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Kiosk.svelte b/frontend/src/pages/Kiosk.svelte index 983e039..c9eb58a 100644 --- a/frontend/src/pages/Kiosk.svelte +++ b/frontend/src/pages/Kiosk.svelte @@ -149,7 +149,7 @@
{state.volunteer.name}
{state.volunteer.email || ''} - {state.volunteer.is_lead ? ' · Department Lead' : ''} + {state.volunteer.is_lead ? ' · Co-Lead' : ''}
Token: {token}
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 2d0b555..65391e0 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -397,6 +397,9 @@ {#each assigned as { vs, volunteer }}
{volunteer.name} + {#if volunteer.is_lead} + Co-Lead + {/if} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {/if} @@ -514,6 +517,14 @@ font-size: 0.78rem; font-weight: 500; } + .chip-lead { + font-size: 0.68rem; + font-weight: 600; + background: rgba(245,158,11,0.2); + color: var(--c-warn); + padding: 0.05rem 0.3rem; + border-radius: 99px; + } .board-vol-remove { background: none; border: none; diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 1bf13c4..e028389 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -216,7 +216,7 @@ {v.name} {#if v.is_lead} - Lead + Co-Lead {/if} {#if !v.participant_id} No ticket @@ -253,8 +253,8 @@ {#if canManage} From a60ef7d25b4397a08dbb1a084a652bfd2e70c4c9 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 23:02:35 -0600 Subject: [PATCH 22/54] Updated docs. --- README.md | 25 ++++++------- docs/INSTALLATION.md | 22 +++++++----- docs/USAGE.md | 86 +++++++++++++++++++++----------------------- 3 files changed, 66 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 88b09ef..80a43e5 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ # Turnpike -Self-hosted event attendee and volunteer management. One instance, one event. +Self-hosted event ticketing 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 +- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering -- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking -- **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 +- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking +- **Volunteer kiosk** — code-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in - **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness -- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate +- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper - **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 +- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | -| `coordinator` | All departments: volunteers, shifts, schedule. 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 | +| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts | +| `ticketing` | Participants, tickets, import. No user management | +| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | +| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | +| `gatekeeper` | Full-screen check-in UI with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -91,7 +92,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server ## Documentation -- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup ## License diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 1f9a967..9bc0dbc 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -105,23 +105,27 @@ docker run -p 8180:8180 \ ## NixOS -Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): +Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build: ```nix +frontendDist = pkgs.buildNpmPackage { + pname = "turnpike-frontend"; + src = "${src}/frontend"; + npmDepsHash = "sha256-..."; + buildPhase = "npm run build"; + installPhase = "cp -r dist $out"; +}; + turnpike = pkgs.buildGoModule { pname = "turnpike"; - version = "0.1.0"; - src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ - vendorHash = null; + src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; + vendorHash = "sha256-..."; env.CGO_ENABLED = 0; + preBuild = "cp -r ${frontendDist} frontend/dist"; }; ``` -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`. +A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`. ## Reverse Proxy diff --git a/docs/USAGE.md b/docs/USAGE.md index de4e765..11684d4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets | 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, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | -| **volunteer_lead** | Schedule, 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 | +| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers | +| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | +| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | +| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | +| **gatekeeper** | Full-screen Gate UI | Check in ticket holders (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. +Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). ## Event Setup -1. **Configure your event** — go to the Dashboard and set the event name and dates. +1. **Configure your event** — set the event name, venue, dates, and timezone via the API (`PUT /api/event`). These appear on the Dashboard. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import attendees** — see next section. +3. **Import participants** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. -## Importing Attendees +## Importing Participants Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Name | +| `Patron Name` | Ticket name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Name | +| `name` (required) | Ticket name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -53,27 +52,21 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically. -### Party-size dedup +### Participants and tickets -CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: +Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates). -- First row for "Titania Fairweather" (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. +Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. ## Volunteer Signup -Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required. +Turnpike provides a public signup form for volunteers at `/volunteer-signup`. No login is required. ### Signup flow 1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note. -2. Turnpike creates a volunteer record and auto-links it to an existing attendee by email match, or creates a new attendee record. -3. A confirmation email is sent with a unique link (`/#/confirm/{token}`). +2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record. +3. A confirmation email is sent with a unique link (`/confirm/{token}`). 4. The volunteer clicks the link to confirm their email. 5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection. @@ -90,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls: In **Settings**, the "Shift Signups" card has an open/close toggle: -- **Opening** signups generates kiosk tokens for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **Opening** signups generates kiosk codes for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. - **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email. @@ -100,11 +93,11 @@ If a volunteer confirms their email while signups are already open, they receive 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 +- Mark volunteers as co-leads - 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. +Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. ## Shift Scheduling @@ -128,15 +121,17 @@ 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. +Kiosk links are generated and distributed automatically through the volunteer signup flow: + +1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. +2. In **Settings**, open shift signups. This generates kiosk codes for all confirmed volunteers and emails them their links. A confirmation dialog warns before sending. +3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. + +**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk 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: +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 @@ -144,20 +139,19 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. 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. +No login is required. The kiosk code authenticates the request. -### Token format +### Code 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). +Kiosk codes 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: +Users with the **gatekeeper** 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. +- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). +- **Volunteer dual check-in** — if a ticket holder is also a volunteer, 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. @@ -170,7 +164,7 @@ The Schedule page is the primary UI for managing shifts and volunteer assignment - 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. +**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). Actions available: - Create new shifts (+ Add shift button) @@ -182,7 +176,7 @@ Actions available: ## SMTP Configuration -SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): +SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -203,13 +197,13 @@ 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. +- **Sync** pulls all changes from the server on startup and periodically thereafter. 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: +CSV exports are available from the Participants 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. +- **Participant export** — all participant records with check-in status +- **Ticket export** — all ticket records with codes and check-in status From 4d3da023fcbfc38a1f668d7af840ae9c0b626842 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 23:06:03 -0600 Subject: [PATCH 23/54] Added event edit. --- docs/USAGE.md | 2 +- frontend/src/pages/Settings.svelte | 72 +++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 11684d4..6c5b9b7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -22,7 +22,7 @@ Coleads are scoped to one or more departments. When creating a colead user, assi ## Event Setup -1. **Configure your event** — set the event name, venue, dates, and timezone via the API (`PUT /api/event`). These appear on the Dashboard. +1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). 3. **Import participants** — see next section. 4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 5caf2ee..138af08 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -1,9 +1,11 @@ diff --git a/frontend/src/pages/GateUI.svelte b/frontend/src/pages/GateKiosk.svelte similarity index 100% rename from frontend/src/pages/GateUI.svelte rename to frontend/src/pages/GateKiosk.svelte diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2495958..5eb7610 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -424,9 +424,9 @@
{#if tk.checked_in_at} - In {fmtTime(tk.checked_in_at)} + Checked in {fmtTime(tk.checked_in_at)} {:else} - Pending + Not checked in {/if}
{tk.source}
diff --git a/frontend/src/pages/Kiosk.svelte b/frontend/src/pages/VolunteerKiosk.svelte similarity index 100% rename from frontend/src/pages/Kiosk.svelte rename to frontend/src/pages/VolunteerKiosk.svelte diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e028389..1e6eea4 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -183,8 +183,8 @@ {/if} {filtered.length} shown @@ -236,8 +236,8 @@ {/if} - - {v.checked_in ? 'Checked in' : 'Pending'} + + {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} {#if v.checked_in_at}
From 2b409c65c1c00954b4e252e5b73849c4616f47e1 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:35:35 -0600 Subject: [PATCH 26/54] Added check-in to admin Participants view. --- frontend/src/pages/Participants.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 5eb7610..26f5c63 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -129,6 +129,16 @@ } } + async function checkInTicket(tk) { + error = '' + try { + const result = await api.tickets.checkIn(tk.id) + if (result.ticket) await db.tickets.put(result.ticket) + } catch (err) { + error = err.message + } + } + function fmtTime(ts) { if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -426,7 +436,7 @@ {#if tk.checked_in_at} Checked in {fmtTime(tk.checked_in_at)} {:else} - Not checked in + {/if}
{tk.source}
From 87da9cf97f55bb1fc4243fda8acc219f4f57e5e9 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:51:40 -0600 Subject: [PATCH 27/54] Updated docs. --- README.md | 6 +++--- docs/USAGE.md | 21 +++++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 80a43e5..71132c4 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio - **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in - **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering - **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking -- **Volunteer kiosk** — code-authenticated self-service shift signup, no login required -- **Gate check-in** — full-screen UI with QR scanner, volunteer dual check-in +- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling +- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers - **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness - **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync @@ -64,7 +64,7 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | `ticketing` | Participants, tickets, import. No user management | | `staffing` | All departments: volunteers, shifts, schedule. No user management or settings | | `colead` | Own department only: volunteers and shifts scoped to assigned department(s) | -| `gatekeeper` | Full-screen check-in UI with QR scanner. No access to other pages | +| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. diff --git a/docs/USAGE.md b/docs/USAGE.md index 6c5b9b7..23572eb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -16,7 +16,7 @@ After logging in, create accounts for your team under **Users**. Each user gets | **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports | | **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings | | **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only | -| **gatekeeper** | Full-screen Gate UI | Check in ticket holders (search + QR scan). No access to other pages | +| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages | Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). @@ -95,7 +95,15 @@ Under **Volunteers**, you can: - Create volunteers manually (name, email, department) - Assign volunteers to departments - Mark volunteers as co-leads -- Check in volunteers +- Mark volunteers as ready (briefed at the volunteer station) + +### Volunteer statuses + +| Status | Meaning | +|--------|---------| +| **Unconfirmed** | Signed up but hasn't confirmed their email | +| **Confirmed** | Email confirmed, not yet briefed | +| **Ready** | Briefed at the volunteer station, has what they need to report for shifts | Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. @@ -117,7 +125,7 @@ Shifts can be reordered within a department to reflect priority or sequence usin ## Volunteer Kiosk -The kiosk lets volunteers self-select shifts without logging in. +The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in. ### Setup @@ -145,15 +153,16 @@ No login is required. The kiosk code authenticates the request. Kiosk codes 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 +## Gate Kiosk -Users with the **gatekeeper** role see a dedicated full-screen UI: +Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. - **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). -- **Volunteer dual check-in** — if a ticket holder is also a volunteer, 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. +Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets. + 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 From 07f7d3d245cebb3e112647076e0c4f4258833fc8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:51:58 -0600 Subject: [PATCH 28/54] Revised views for mobile. --- frontend/src/app.css | 28 ++++++++++++++++++++++++++ frontend/src/pages/Participants.svelte | 16 ++++++++++++--- frontend/src/pages/Users.svelte | 20 ++++++++++++------ frontend/src/pages/Volunteers.svelte | 16 +++++++++++---- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index c5de1fe..d36f2f7 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -207,4 +207,32 @@ tr:hover td { background: rgba(255,255,255,0.02); } } .page { padding: 1rem; } .stats { grid-template-columns: repeat(2, 1fr); } + + /* Touch targets */ + .btn { min-height: 44px; padding: 0.6rem 1rem; } + .btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; } + + /* Page header & actions */ + .page-header { flex-wrap: wrap; gap: 0.75rem; } + .page-title { width: 100%; } + .actions { flex-wrap: wrap; } + + /* Search bar */ + .search-bar { flex-wrap: wrap; } + .search-bar input { max-width: none; flex: 1 1 100%; } + + /* Table → card layout */ + .table-wrap { overflow-x: visible; } + table { display: block; } + thead { display: none; } + tbody { display: flex; flex-direction: column; gap: 0.5rem; } + tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; + padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg); + background: var(--c-surface); } + tr:hover td { background: transparent; } + td { display: inline; padding: 0; border: none; } + td:empty { display: none; } + + /* Forms */ + .form-grid { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 26f5c63..ef2274f 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -243,7 +243,7 @@ {#if showAdd && canManage}
-
+
@@ -362,7 +362,7 @@ onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null} style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''} > - + {p.preferred_name || '—'} {#if p.pronouns} · {p.pronouns} @@ -397,7 +397,7 @@ {/if} {#if canManage} - + {#if !mergeMode} @@ -504,4 +504,14 @@ .edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; } .edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; } + @media (max-width: 640px) { + .td-name { width: 100%; } + .td-actions { width: 100%; } + .ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; } + .ticket-rows td { width: 100%; } + .ticket-row { flex-direction: column; gap: 0.35rem; } + .ticket-row div:last-child { text-align: left; } + .edit-row { padding: 0.75rem; } + .edit-row td { width: 100%; } + } diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c683fb9..c649964 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -148,7 +148,7 @@ {#if showAdd}
-
+
@@ -213,8 +213,8 @@ {#each users as u (u.id)} {#if editID === u.id} - - {u.username} {#if u.id === me}you{/if} + + {u.username} {#if u.id === me}you{/if} @@ -213,7 +213,7 @@ {@const dept = deptFor(v.department_id)} {@const participant = participantFor(v.participant_id)} - + {v.name} {#if v.is_lead} Co-Lead @@ -245,13 +245,13 @@
{/if} - + {#if !v.checked_in} checkIn(v)} /> {/if} {#if canManage} - +
{/if}
+ + From d439306657bbc2edc6d6ee1a93606c1bc52eb342 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 09:56:54 -0600 Subject: [PATCH 29/54] Moved action buttons on mobile cards. --- frontend/src/pages/Departments.svelte | 23 ++++++++++++++++------- frontend/src/pages/Participants.svelte | 4 +--- frontend/src/pages/Users.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 12 +++++++----- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index b7fd627..c2cf82b 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -100,7 +100,7 @@ {#if showAdd && canCreate}
-
+
@@ -142,8 +142,8 @@ {#each $allDepts ?? [] as d (d.id)} {#if editID === d.id} - - + +
@@ -153,7 +153,7 @@ {#if canCreate} - +
{#if canDelete} @@ -188,3 +188,12 @@
{/if}
+ + diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index ef2274f..55714a6 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -506,11 +506,9 @@ @media (max-width: 640px) { .td-name { width: 100%; } - .td-actions { width: 100%; } + .td-actions { width: 100%; display: flex; justify-content: flex-end; } .ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; } .ticket-rows td { width: 100%; } - .ticket-row { flex-direction: column; gap: 0.35rem; } - .ticket-row div:last-child { text-align: left; } .edit-row { padding: 0.75rem; } .edit-row td { width: 100%; } } diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c649964..ee6b18a 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -278,7 +278,7 @@ diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 8e5827a..ade758b 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -228,14 +228,14 @@
{v.note}
{/if} - + {#if dept} {dept.name} {:else} — {/if} - + {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} @@ -269,8 +269,10 @@ From 62b3dece84b27f14989e07ca6322626dc1037598 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 10:35:27 -0600 Subject: [PATCH 30/54] Added Participant list to Gate kiosk. --- frontend/src/pages/GateKiosk.svelte | 67 ++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/GateKiosk.svelte b/frontend/src/pages/GateKiosk.svelte index 6c7dc71..d1eeaa1 100644 --- a/frontend/src/pages/GateKiosk.svelte +++ b/frontend/src/pages/GateKiosk.svelte @@ -7,6 +7,8 @@ let { session, onLogout } = $props() let search = $state('') + let manuallySelectedId = $state(null) + let showAll = $state(false) let error = $state('') let scannerMsg = $state('') let qrSupported = $state(false) @@ -44,22 +46,44 @@ return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null }) - // Name/email search across participants + const allParticipantsSorted = $derived.by(() => + ($participants ?? []) + .filter(p => !p.deleted_at) + .sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || '')) + ) + + // Clear manual selection whenever search text changes + $effect(() => { + search + manuallySelectedId = null + }) + + // Name/email/ticket-name search across participants const filteredParticipants = $derived.by(() => { if (matchedTicket) return [] const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] + const byTicketName = new Set( + ($tickets ?? []) + .filter(t => t.name?.toLowerCase().includes(s)) + .map(t => t.participant_id) + .filter(Boolean) + ) return ($participants ?? []) .filter(p => p.preferred_name?.toLowerCase().includes(s) || - p.email?.toLowerCase().includes(s) + p.email?.toLowerCase().includes(s) || + byTicketName.has(p.id) ) .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) .slice(0, 8) }) - // Auto-select when exactly one participant matches + // Manual selection takes priority; fall back to auto-select on single match const selectedParticipant = $derived.by(() => { + if (manuallySelectedId) { + return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null + } if (filteredParticipants.length === 1) return filteredParticipants[0] return null }) @@ -165,6 +189,9 @@ {scanning ? '■ Stop' : '⊡ Scan QR'} {/if} +
{#if scanning} @@ -211,7 +238,13 @@ {:else if selectedParticipant} {@const pts = ticketsFor(selectedParticipant.id)}
-
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+
+
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+ {#if manuallySelectedId} + + {/if} +
{#if selectedParticipant.email}
{selectedParticipant.email}
{/if} @@ -243,7 +276,7 @@ {#each filteredParticipants as p} {@const pts = ticketsFor(p.id)} {@const ci = pts.filter(t => t.checked_in_at).length} - + {/each} +
+ {/if} +
Recent Check-ins
@@ -385,7 +439,8 @@ padding: 1.25rem; margin-bottom: 1rem; } - .gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; } + .gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; } + .gate-match-name { font-size: 1.4rem; font-weight: 700; } .gate-match-sub { color: var(--c-muted); font-size: 0.875rem; } .gate-party { margin: 0.5rem 0; From 72b245d6d6e01b3a29b921f8ec4d8cda553448e4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 15:52:40 -0600 Subject: [PATCH 31/54] Set up Unconfirmed -> Registered -> Confirmed -> Ready flow for Volunteers --- db.go | 26 +++++++++++++- docs/USAGE.md | 13 ++++--- frontend/src/api.js | 1 + frontend/src/app.css | 7 ++-- frontend/src/pages/Volunteers.svelte | 53 ++++++++++++++++++++++------ handle_volunteers.go | 14 ++++++++ main.go | 1 + 7 files changed, 95 insertions(+), 20 deletions(-) diff --git a/db.go b/db.go index d93807d..be830db 100644 --- a/db.go +++ b/db.go @@ -174,6 +174,8 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") + addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "volunteers", "confirmed_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`) @@ -392,6 +394,8 @@ type Volunteer struct { IsLead bool `json:"is_lead"` CheckedIn bool `json:"checked_in"` CheckedInAt *string `json:"checked_in_at,omitempty"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` ConfirmationToken *string `json:"-"` Note string `json:"note"` @@ -1184,6 +1188,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), v.department_id, v.is_lead, v.checked_in, v.checked_in_at, + v.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` @@ -1293,6 +1298,19 @@ func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { return v, nil } +func (app *App) confirmVolunteer(id int) (*Volunteer, error) { + t := now() + _, err := app.db.Exec( + `UPDATE volunteers SET confirmed=1, confirmed_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND confirmed=0`, + t, t, id, + ) + if err != nil { + return nil, err + } + return app.getVolunteer(id) +} + func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { rows, err := db.Query(q, args...) if err != nil { @@ -1303,12 +1321,14 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { for rows.Next() { var v Volunteer var participantID, attendeeID, deptID sql.NullInt64 - var isLead, checkedIn, emailConfirmed int + var isLead, checkedIn, confirmed, emailConfirmed int var confirmationToken sql.NullString + var confirmedAt sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, + &confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { @@ -1329,8 +1349,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { if confirmationToken.Valid { v.ConfirmationToken = &confirmationToken.String } + if confirmedAt.Valid { + v.ConfirmedAt = &confirmedAt.String + } v.IsLead = isLead == 1 v.CheckedIn = checkedIn == 1 + v.Confirmed = confirmed == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) } diff --git a/docs/USAGE.md b/docs/USAGE.md index 23572eb..949f71b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -99,11 +99,14 @@ Under **Volunteers**, you can: ### Volunteer statuses -| Status | Meaning | -|--------|---------| -| **Unconfirmed** | Signed up but hasn't confirmed their email | -| **Confirmed** | Email confirmed, not yet briefed | -| **Ready** | Briefed at the volunteer station, has what they need to report for shifts | +| Status | Meaning | Who sets it | +|--------|---------|-------------| +| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) | +| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) | +| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | +| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | + +**Confirmation** is a deliberate staff action — it signals that a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row. Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. diff --git a/frontend/src/api.js b/frontend/src/api.js index b0767e6..6700d4b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -79,6 +79,7 @@ export const api = { 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' }), + confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { 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' }), }, diff --git a/frontend/src/app.css b/frontend/src/app.css index d36f2f7..0cd0ee8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -129,9 +129,10 @@ tr:hover td { background: rgba(255,255,255,0.02); } 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-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } -.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } +.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } +.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } +.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } +.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } .badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); } .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); } diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index ade758b..e26ee6a 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -8,7 +8,7 @@ let search = $state('') let filterDept = $state('') - let filterChecked = $state('') + let filterStatus = $state('') let error = $state('') let showAdd = $state(false) let adding = $state(false) @@ -20,6 +20,7 @@ const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) // Auto-filter coleads to their department on mount @@ -33,6 +34,7 @@ db.volunteers.filter(v => !v.deleted_at).toArray() ) const allParticipants = liveQuery(() => db.participants.toArray()) + const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray()) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) @@ -44,8 +46,10 @@ return list .filter(v => { if (filterDept && v.department_id !== parseInt(filterDept)) return false - if (filterChecked === 'true' && !v.checked_in) return false - if (filterChecked === 'false' && v.checked_in) return false + if (filterStatus === 'unconfirmed' && v.email_confirmed) return false + if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false + if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false + if (filterStatus === 'ready' && !v.checked_in) return false if (s && !v.name.toLowerCase().includes(s) && !(v.email || '').toLowerCase().includes(s)) return false return true @@ -62,6 +66,15 @@ } } + async function confirmVolunteer(v) { + try { + const updated = await api.volunteers.confirm(v.id) + await db.volunteers.put(updated) + } catch (err) { + error = err.message + } + } + async function addVolunteer(e) { e.preventDefault() adding = true @@ -110,6 +123,11 @@ return ($allDepts ?? []).find(d => d.id === id) } + function participantHasTickets(participantId) { + if (!participantId) return false + return ($allTickets ?? []).some(t => t.participant_id === participantId) + } + function participantFor(id) { return ($allParticipants ?? []).find(p => p.id === id) ?? null } @@ -181,10 +199,12 @@ {/each} {/if} - + + + + + {filtered.length} shown @@ -219,7 +239,9 @@ Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket {/if} {#if v.email}
{v.email}
@@ -236,9 +258,15 @@ {/if} - - {v.checked_in ? 'Ready' : v.email_confirmed ? 'Confirmed' : 'Unconfirmed'} - + {#if v.checked_in} + Ready + {:else if v.confirmed} + Confirmed + {:else if v.email_confirmed} + Registered + {:else} + Unconfirmed + {/if} {#if v.checked_in_at}
{new Date(v.checked_in_at).toLocaleTimeString()} @@ -252,6 +280,9 @@ {#if canManage} + {#if canConfirm && v.email_confirmed && !v.confirmed && v.department_id} + + {/if} - {/if} - - + {#if editID === v.id} + + + {v.name} + {#if v.email}
{v.email}
{/if} - {/if} - + + + + + + + + + + + + + + + {:else} + + + {v.name} + {#if v.is_lead} + Co-Lead + {/if} + {#if !v.participant_id} + No ticket + {:else if !participantHasTickets(v.participant_id)} + No ticket + {/if} + {#if v.ticket_name && v.ticket_name !== v.name} +
Ticket: {v.ticket_name}
+ {/if} + {#if v.email} +
{v.email}
+ {/if} + {#if v.note} +
{v.note}
+ {/if} + + + {#if dept} + {dept.name} + {:else} + — + {/if} + + + {#if v.checked_in} + Ready + {:else if v.confirmed} + Confirmed + {:else if v.email_confirmed} + Registered + {:else} + Unconfirmed + {/if} + {#if v.checked_in_at} +
+ {new Date(v.checked_in_at).toLocaleTimeString()} +
+ {/if} + + + {#if !v.checked_in} + checkIn(v)} /> + {/if} + + {#if canManage} + + {#if canConfirm && v.email_confirmed && !v.confirmed} + + {/if} + + + + {/if} + + {/if} {/each} @@ -305,5 +362,7 @@ .td-dept { width: 100%; order: 3; } .td-status { width: 100%; order: 4; } .td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; } + .edit-row td { width: 100%; } + .td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; } } From cc4dd7643850606c4bcd867544f3c9c60a143cf0 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 16:51:39 -0600 Subject: [PATCH 34/54] Set co-leads to confirmed automatically, and updated test and documents. --- docs/USAGE.md | 12 ++-- handle_volunteers.go | 4 ++ handle_volunteers_test.go | 141 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 handle_volunteers_test.go diff --git a/docs/USAGE.md b/docs/USAGE.md index 949f71b..c08ec50 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -83,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls: In **Settings**, the "Shift Signups" card has an open/close toggle: -- **Opening** signups generates kiosk codes for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending. +- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending. - **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email. @@ -92,9 +92,9 @@ If a volunteer confirms their email while signups are already open, they receive Under **Volunteers**, you can: -- Create volunteers manually (name, email, department) -- Assign volunteers to departments -- Mark volunteers as co-leads +- Create volunteers manually (name, email, department, co-lead, note) +- Edit existing volunteers (department, co-lead, note) via the inline Edit button +- Confirm registered volunteers (admin, staffing, colead) - Mark volunteers as ready (briefed at the volunteer station) ### Volunteer statuses @@ -106,7 +106,7 @@ Under **Volunteers**, you can: | **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead | | **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in | -**Confirmation** is a deliberate staff action — it signals that a volunteer has been assigned to a department and you're expecting them. Only volunteers who have been assigned a department can be confirmed. Use the **Confirm** button on a registered volunteer's row. +**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them. Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email. @@ -135,7 +135,7 @@ The Volunteer Kiosk is the public-facing flow for volunteers: signup, email conf Kiosk links are generated and distributed automatically through the volunteer signup flow: 1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email. -2. In **Settings**, open shift signups. This generates kiosk codes for all confirmed volunteers and emails them their links. A confirmation dialog warns before sending. +2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending. 3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL. diff --git a/handle_volunteers.go b/handle_volunteers.go index 15f976f..584927b 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -109,6 +109,10 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusInternalServerError) return } + + if v.IsLead { + app.confirmVolunteer(id) + } updated, _ := app.getVolunteer(id) writeJSON(w, updated) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go new file mode 100644 index 0000000..e10815c --- /dev/null +++ b/handle_volunteers_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestConfirmVolunteer(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Titania", Email: "titania@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + result := parseJSON(t, w) + vol := result["confirmed"] + if vol != true { + t.Error("expected confirmed=true in response") + } + + got, _ := app.getVolunteer(v.ID) + if got == nil || !got.Confirmed { + t.Error("volunteer should be confirmed in DB") + } + if got.ConfirmedAt == nil { + t.Error("confirmed_at should be set") + } +} + +func TestConfirmVolunteerIdempotent(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + + // Confirm twice — second should be a no-op, not an error. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("first confirm: %d", w.Code) + } + + w = httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != 200 { + t.Fatalf("second confirm: %d", w.Code) + } +} + +func TestConfirmVolunteerRequiresRole(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + // Ticketing role should NOT be able to confirm volunteers. + ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) + tok := testToken(t, app, ticketing) + + v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for ticketing role, got %d", w.Code) + } +} + +func TestUpdateVolunteerDepartment(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Gate"}) + v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + + // Assign department via update. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Hermia", "department_id": dept.ID, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ := app.getVolunteer(v.ID) + if got.DepartmentID == nil || *got.DepartmentID != dept.ID { + t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID) + } +} + +func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { + app := testApp(t) + mux := testMux(app) + admin := testAdminUser(t, app) + tok := testToken(t, app, admin) + + dept, _ := app.createDepartment(Department{Name: "Build"}) + deptID := dept.ID + v, _ := app.createVolunteer(Volunteer{ + Name: "Lysander", Email: "lys@test.com", + DepartmentID: &deptID, EmailConfirmed: true, + }) + + // Verify not confirmed before update. + got, _ := app.getVolunteer(v.ID) + if got.Confirmed { + t.Fatal("should not be confirmed before update") + } + + // Update is_lead=true should auto-confirm. + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "name": "Lysander", "department_id": deptID, "is_lead": true, + }, tok)) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + got, _ = app.getVolunteer(v.ID) + if !got.IsLead { + t.Error("expected is_lead=true") + } + if !got.Confirmed { + t.Error("co-lead should be auto-confirmed") + } +} From e722ef055edd0ba7de3fe1db37397fe00f391151 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 17:15:41 -0600 Subject: [PATCH 35/54] Move Ticket Name to the Participant model. --- db.go | 30 +++++++++++++------------- frontend/src/pages/Participants.svelte | 4 ++++ frontend/src/pages/Volunteers.svelte | 3 --- handle_signup.go | 10 ++++----- handle_signup_test.go | 7 +++--- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/db.go b/db.go index 9129489..c7a1089 100644 --- a/db.go +++ b/db.go @@ -201,6 +201,7 @@ func migrateV2(db *sql.DB) error { // and links volunteers to participants via participant_id. func migrateV3(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") + addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") // Seed participants from volunteers first (better name data: preferred_name). db.Exec(` @@ -401,7 +402,6 @@ type Volunteer struct { AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat Name string `json:"name"` PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` Email string `json:"email"` Phone string `json:"phone"` Pronouns string `json:"pronouns"` @@ -424,6 +424,7 @@ type Participant struct { ID int `json:"id"` Email string `json:"email"` PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` Phone string `json:"phone"` Pronouns string `json:"pronouns"` Note string `json:"note"` @@ -860,7 +861,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -900,8 +901,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), + `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), ) if err != nil { return nil, err @@ -912,9 +913,9 @@ func (app *App) createParticipant(p Participant) (*Participant, error) { func (app *App) updateParticipant(p Participant) error { _, err := app.db.Exec( - `UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=? + `UPDATE participants SET email=?, preferred_name=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID, ) return err } @@ -957,7 +958,7 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) for rows.Next() { var p Participant if err := rows.Scan( - &p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note, + &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err @@ -1199,7 +1200,6 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - v.ticket_name, COALESCE(NULLIF(p.email,''), v.email), COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), @@ -1210,7 +1210,7 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` // volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1263,9 +1263,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, ticket_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), ) if err != nil { @@ -1277,9 +1277,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, ticket_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.TicketName, v.Email, v.Phone, v.Pronouns, + v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -1340,7 +1340,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var isLead, checkedIn, confirmed, emailConfirmed int var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName, + &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.Email, &v.Phone, &v.Pronouns, &deptID, &isLead, &checkedIn, &v.CheckedInAt, &confirmed, &confirmedAt, diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 55714a6..2867958 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -63,6 +63,7 @@ return ($allTickets ?? []).filter(t => t.participant_id === participantId) } + function checkedInCount(participantId) { return ticketsFor(participantId).filter(t => t.checked_in_at).length } @@ -367,6 +368,9 @@ {#if p.pronouns} · {p.pronouns} {/if} + {#if p.ticket_name && p.ticket_name !== p.preferred_name} +
Ticket: {p.ticket_name}
+ {/if} {#if p.note}
{p.note}
{/if} diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index c8521e1..441e415 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -299,9 +299,6 @@ {:else if !participantHasTickets(v.participant_id)} No ticket {/if} - {#if v.ticket_name && v.ticket_name !== v.name} -
Ticket: {v.ticket_name}
- {/if} {#if v.email}
{v.email}
{/if} diff --git a/handle_signup.go b/handle_signup.go index bd9f091..77a7c63 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -75,12 +75,13 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { return } // Update participant's personal details if they signed up with more info. - if body.Phone != "" || body.Pronouns != "" { + if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" { app.db.Exec(`UPDATE participants SET - phone = CASE WHEN phone = '' THEN ? ELSE phone END, - pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, + phone = CASE WHEN phone = '' THEN ? ELSE phone END, + pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END, + ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END, updated_at = ? - WHERE id = ?`, body.Phone, body.Pronouns, now(), participant.ID) + WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID) } confirmToken, err := generateConfirmationToken() @@ -93,7 +94,6 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { ParticipantID: &participant.ID, Name: body.PreferredName, PreferredName: body.PreferredName, - TicketName: body.TicketName, Email: body.Email, Phone: body.Phone, Pronouns: body.Pronouns, diff --git a/handle_signup_test.go b/handle_signup_test.go index c240596..2b63c16 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -337,8 +337,8 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { if p.PreferredName != "Titania" { t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania") } - if vol.TicketName != "Titania Fairweather" { - t.Errorf("vol.TicketName = %q, want %q", vol.TicketName, "Titania Fairweather") + if p.TicketName != "Titania Fairweather" { + t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather") } } @@ -349,12 +349,11 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"}) token := "abc123def456" app.createVolunteer(Volunteer{ Name: "Titania", PreferredName: "Titania", - TicketName: "Titania Fairweather", Email: "titania@example.com", ParticipantID: &participant.ID, ConfirmationToken: &token, From 4c462c9d4757c70d5e7694e0b93ee49a99021f38 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 17:34:50 -0600 Subject: [PATCH 36/54] Refactored volunteer check_in as ready status. --- db.go | 49 ++++++++++++++------ frontend/src/api.js | 2 +- frontend/src/components/CheckInButton.svelte | 2 +- frontend/src/pages/Dashboard.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 18 +++---- handle_volunteers.go | 4 +- main.go | 2 +- 7 files changed, 51 insertions(+), 28 deletions(-) diff --git a/db.go b/db.go index c7a1089..514460e 100644 --- a/db.go +++ b/db.go @@ -93,8 +93,8 @@ func migrate(db *sql.DB) error { 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, + ready INTEGER NOT NULL DEFAULT 0, + ready_at TEXT, note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -177,6 +177,8 @@ func migrateV2(db *sql.DB) error { addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") + renameColumnIfExists(db, "volunteers", "checked_in", "ready") + renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at") db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) // Migrate kiosk codes from tickets to volunteers (idempotent). db.Exec(` @@ -340,6 +342,27 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) { db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) } +func renameColumnIfExists(db *sql.DB, table, oldName, newName string) { + found := false + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + 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 == oldName { + found = true + } + } + rows.Close() + if found { + db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`) + } +} + // --- Types --- const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, @@ -407,8 +430,8 @@ type Volunteer struct { Pronouns string `json:"pronouns"` DepartmentID *int `json:"department_id,omitempty"` IsLead bool `json:"is_lead"` - CheckedIn bool `json:"checked_in"` - CheckedInAt *string `json:"checked_in_at,omitempty"` + Ready bool `json:"ready"` + ReadyAt *string `json:"ready_at,omitempty"` Confirmed bool `json:"confirmed"` ConfirmedAt *string `json:"confirmed_at,omitempty"` EmailConfirmed bool `json:"email_confirmed"` @@ -1203,14 +1226,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id, COALESCE(NULLIF(p.email,''), v.email), COALESCE(NULLIF(p.phone,''), v.phone), COALESCE(NULLIF(p.pronouns,''), v.pronouns), - v.department_id, v.is_lead, v.checked_in, v.checked_in_at, + v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` // volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1292,13 +1315,13 @@ func (app *App) deleteVolunteer(id int) error { return err } -// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, +// markVolunteerReady marks the volunteer as ready and, if linked to an attendee, // also increments the attendee's checked_in_count. -func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { +func (app *App) markVolunteerReady(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`, + `UPDATE volunteers SET ready=1, ready_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND ready=0`, t, t, id, ) if err != nil { @@ -1337,12 +1360,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { for rows.Next() { var v Volunteer var participantID, attendeeID, deptID sql.NullInt64 - var isLead, checkedIn, confirmed, emailConfirmed int + var isLead, ready, confirmed, emailConfirmed int var confirmationToken, confirmedAt, kioskCode sql.NullString if err := rows.Scan( &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.Email, &v.Phone, &v.Pronouns, &deptID, - &isLead, &checkedIn, &v.CheckedInAt, + &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, @@ -1371,7 +1394,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { v.KioskCode = &kioskCode.String } v.IsLead = isLead == 1 - v.CheckedIn = checkedIn == 1 + v.Ready = ready == 1 v.Confirmed = confirmed == 1 v.EmailConfirmed = emailConfirmed == 1 result = append(result, v) diff --git a/frontend/src/api.js b/frontend/src/api.js index 6700d4b..686faa8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -78,7 +78,7 @@ export const api = { 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' }), + markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }), confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { 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' }), diff --git a/frontend/src/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte index f0af073..cbd4bd2 100644 --- a/frontend/src/components/CheckInButton.svelte +++ b/frontend/src/components/CheckInButton.svelte @@ -9,5 +9,5 @@ diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 9a69d10..e5a26de 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -31,7 +31,7 @@ return vols }) const volTotal = $derived(volunteers.length) - const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).length) + const volCheckedIn = $derived(volunteers.filter(v => v.ready).length) const volLeads = $derived(volunteers.filter(v => v.is_lead).length) // Shift stats (scoped for colead) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 441e415..f477e64 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -54,8 +54,8 @@ if (filterDept && v.department_id !== parseInt(filterDept)) return false if (filterStatus === 'unconfirmed' && v.email_confirmed) return false if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false - if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false - if (filterStatus === 'ready' && !v.checked_in) return false + if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false + if (filterStatus === 'ready' && !v.ready) return false if (s && !v.name.toLowerCase().includes(s) && !(v.email || '').toLowerCase().includes(s)) return false return true @@ -63,9 +63,9 @@ .sort((a, b) => a.name.localeCompare(b.name)) }) - async function checkIn(v) { + async function markReady(v) { try { - const updated = await api.volunteers.checkIn(v.id) + const updated = await api.volunteers.markReady(v.id) await db.volunteers.put(updated) } catch (err) { error = err.message @@ -314,7 +314,7 @@ {/if} - {#if v.checked_in} + {#if v.ready} Ready {:else if v.confirmed} Confirmed @@ -323,15 +323,15 @@ {:else} Unconfirmed {/if} - {#if v.checked_in_at} + {#if v.ready_at}
- {new Date(v.checked_in_at).toLocaleTimeString()} + {new Date(v.ready_at).toLocaleTimeString()}
{/if} - {#if !v.checked_in} - checkIn(v)} /> + {#if v.confirmed && !v.ready} + markReady(v)} /> {/if} {#if canManage} diff --git a/handle_volunteers.go b/handle_volunteers.go index 584927b..845bddd 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -130,14 +130,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) { +func (app *App) handleMarkVolunteerReady(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) + v, err := app.markVolunteerReady(id, claims.UserID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/main.go b/main.go index ea79877..be9fc53 100644 --- a/main.go +++ b/main.go @@ -125,7 +125,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead")) mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) From c03498b59e8cf492db6c05b68f1c4e55205f7488 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 19:11:58 -0600 Subject: [PATCH 37/54] Fixed volunteer filters. --- frontend/src/pages/ScheduleBoard.svelte | 5 ++++- frontend/src/pages/Volunteers.svelte | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 65391e0..6755588 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -414,7 +414,10 @@
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index f477e64..5f5f3c8 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -29,10 +29,11 @@ const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) const myDeptIDs = $derived(session?.user?.department_ids ?? []) - // Auto-filter coleads to their department on mount + let deptInitialized = $state(false) $effect(() => { - if (role === 'colead' && myDeptIDs.length > 0 && !filterDept) { + if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) + deptInitialized = true } }) @@ -228,7 +229,7 @@ {/if} + + +
+
+ +
diff --git a/handle_volunteers.go b/handle_volunteers.go index 845bddd..6832995 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -33,11 +33,15 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { - var v Volunteer - if err := json.NewDecoder(r.Body).Decode(&v); err != nil { + var body struct { + Volunteer + TicketName string `json:"ticket_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } + v := body.Volunteer if v.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return @@ -52,7 +56,9 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { if v.Email != "" && v.ParticipantID == nil { p, _ := app.getParticipantByEmail(v.Email) if p == nil { - p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email}) + p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email, TicketName: body.TicketName}) + } else if body.TicketName != "" && p.TicketName == "" { + app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) } if p != nil { v.ParticipantID = &p.ID From e7e542c03cc55dba0fa29a8443830fbc283f9b3e Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 20:17:25 -0600 Subject: [PATCH 39/54] Require email on Volunteer entry. --- frontend/src/pages/Volunteers.svelte | 4 ++-- handle_volunteers.go | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 06f6aac..e90bf80 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -190,8 +190,8 @@
- - + +
diff --git a/handle_volunteers.go b/handle_volunteers.go index 6832995..b7e385a 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -46,6 +46,10 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "name is required", http.StatusBadRequest) return } + if v.Email == "" { + writeError(w, "email is required", http.StatusBadRequest) + return + } claims := claimsFromContext(r) if claims.Role == "colead" { if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { From fcf5bf1f34369da659490193fd39e04964c2df55 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Thu, 5 Mar 2026 20:28:21 -0600 Subject: [PATCH 40/54] Generate confirmation email for manually-entered volunteers. --- handle_volunteers.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/handle_volunteers.go b/handle_volunteers.go index b7e385a..0a9fec0 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "log" "net/http" "strconv" ) @@ -68,11 +69,22 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { v.ParticipantID = &p.ID } } + confirmToken, err := generateConfirmationToken() + if err != nil { + writeError(w, "internal error", http.StatusInternalServerError) + return + } + v.ConfirmationToken = &confirmToken created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } + go func() { + if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", v.Email, err) + } + }() w.WriteHeader(http.StatusCreated) writeJSON(w, created) } From 7d56ef2f339f2a8d0c43dc474a8232dc9645fac7 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Fri, 6 Mar 2026 07:11:19 -0600 Subject: [PATCH 41/54] Moved properties from Volunteer to Participant. --- db.go | 422 +++++++++----------------------------- db_test.go | 9 +- frontend/src/db.js | 5 + handle_kiosk_test.go | 5 +- handle_shifts_test.go | 6 +- handle_signup.go | 25 +-- handle_signup_test.go | 65 +++--- handle_volunteers.go | 66 +++--- handle_volunteers_test.go | 25 ++- 9 files changed, 200 insertions(+), 428 deletions(-) diff --git a/db.go b/db.go index 514460e..bda44ed 100644 --- a/db.go +++ b/db.go @@ -86,21 +86,24 @@ func migrate(db *sql.DB) error { 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, - ready INTEGER NOT NULL DEFAULT 0, - ready_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 + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + ready INTEGER NOT NULL DEFAULT 0, + ready_at TEXT, + confirmed INTEGER NOT NULL DEFAULT 0, + confirmed_at TEXT, + kiosk_code 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 UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code + ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; + CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, @@ -119,19 +122,23 @@ func migrate(db *sql.DB) error { 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')), + deleted_at TEXT, PRIMARY KEY (volunteer_id, shift_id) ); CREATE TABLE IF NOT EXISTS participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL DEFAULT '', - preferred_name TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - pronouns TEXT NOT NULL DEFAULT '', - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL DEFAULT '', + preferred_name TEXT NOT NULL DEFAULT '', + ticket_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + pronouns TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + email_confirmed INTEGER NOT NULL DEFAULT 0, + confirmation_token TEXT, + 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_participants_email @@ -159,210 +166,9 @@ func migrate(db *sql.DB) error { 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") - addColumnIfMissing(db, "volunteer_shifts", "deleted_at TEXT") - addColumnIfMissing(db, "volunteers", "preferred_name TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "ticket_name TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''") - addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "volunteers", "confirmation_token TEXT") - addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "volunteers", "confirmed_at TEXT") - addColumnIfMissing(db, "volunteers", "kiosk_code TEXT") - renameColumnIfExists(db, "volunteers", "checked_in", "ready") - renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at") - db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`) - // Migrate kiosk codes from tickets to volunteers (idempotent). - db.Exec(` - UPDATE volunteers SET kiosk_code = ( - SELECT t.code FROM tickets t - WHERE t.participant_id = volunteers.participant_id - AND t.code IS NOT NULL AND t.deleted_at IS NULL - LIMIT 1 - ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`) - // Delete stub tickets whose code has been migrated to the volunteer. - db.Exec(` - DELETE FROM tickets - WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL - AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`) - // 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 migrateV3(db) -} - -// migrateV3 populates participants + tickets from attendees/volunteers, -// and links volunteers to participants via participant_id. -func migrateV3(db *sql.DB) error { - addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)") - addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''") - - // Seed participants from volunteers first (better name data: preferred_name). - db.Exec(` - INSERT OR IGNORE INTO participants (email, preferred_name, phone, pronouns, created_at, updated_at) - SELECT - LOWER(email), - CASE WHEN preferred_name != '' THEN preferred_name ELSE name END, - phone, - pronouns, - created_at, - created_at - FROM volunteers - WHERE email != '' AND deleted_at IS NULL`) - - // Fill in from attendees for emails not yet in participants. - db.Exec(` - INSERT OR IGNORE INTO participants (email, preferred_name, phone, created_at, updated_at) - SELECT LOWER(email), name, phone, created_at, created_at - FROM attendees - WHERE email != '' AND deleted_at IS NULL`) - - // Attendees with no email: create a placeholder participant so tickets aren't orphaned. - rows, _ := db.Query(`SELECT id, name, created_at FROM attendees WHERE email = '' AND deleted_at IS NULL`) - if rows != nil { - type stub struct { - id, name, createdAt string - } - var stubs []stub - for rows.Next() { - var s stub - rows.Scan(&s.id, &s.name, &s.createdAt) - stubs = append(stubs, s) - } - rows.Close() - for _, s := range stubs { - placeholder := fmt.Sprintf("ticket-%s@unknown", s.id) - db.Exec(`INSERT OR IGNORE INTO participants (email, preferred_name, created_at, updated_at) VALUES (?, ?, ?, ?)`, - placeholder, s.name, s.createdAt, s.createdAt) - } - } - - // Link volunteers to participants via email. - db.Exec(` - UPDATE volunteers SET participant_id = ( - SELECT p.id FROM participants p WHERE LOWER(p.email) = LOWER(volunteers.email) - ) - WHERE participant_id IS NULL AND email != ''`) - - // Seed tickets from attendees (1 ticket per attendee row). - db.Exec(` - INSERT OR IGNORE INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at) - SELECT - p.id, - a.name, - a.ticket_type, - CASE WHEN a.ticket_id != '' THEN 'crowdwork' ELSE 'manual' END, - a.ticket_id, - a.ticket_id, - a.volunteer_token, - a.checked_in_at, - a.checked_in_by, - a.created_at, - a.updated_at, - a.deleted_at - FROM attendees a - JOIN participants p ON LOWER(p.email) = LOWER(a.email) OR p.email = 'ticket-' || a.id || '@unknown'`) - - // Volunteers whose participant has no ticket: create a stub ticket so they can get a kiosk code. - db.Exec(` - INSERT OR IGNORE INTO tickets (participant_id, source, created_at, updated_at) - SELECT DISTINCT v.participant_id, 'manual', v.created_at, v.created_at - FROM volunteers v - WHERE v.participant_id IS NOT NULL - AND v.deleted_at IS NULL - AND NOT EXISTS (SELECT 1 FROM tickets t WHERE t.participant_id = v.participant_id AND t.deleted_at IS NULL)`) - - return migrateV4(db) -} - -// migrateV4 renames roles: volunteer_lead→colead, coordinator→staffing, gate→gatekeeper. -func migrateV4(db *sql.DB) error { - var count int - if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE role IN ('volunteer_lead','coordinator','gate')`).Scan(&count); err != nil || count == 0 { - return nil - } - if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { - return err - } - stmts := []string{ - `CREATE TABLE users_v4 ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('admin','ticketing','staffing','colead','gatekeeper')), - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )`, - `INSERT INTO users_v4 (id, username, password_hash, role, created_at) - SELECT id, username, password_hash, - CASE role - WHEN 'volunteer_lead' THEN 'colead' - WHEN 'coordinator' THEN 'staffing' - WHEN 'gate' THEN 'gatekeeper' - ELSE role - END, - created_at - FROM users`, - `DROP TABLE users`, - `ALTER TABLE users_v4 RENAME TO users`, - `PRAGMA foreign_keys = ON`, - } - for _, s := range stmts { - if _, err := db.Exec(s); err != nil { - db.Exec(`PRAGMA foreign_keys = ON`) - return fmt.Errorf("migrateV4: %w", err) - } - } 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) -} - -func renameColumnIfExists(db *sql.DB, table, oldName, newName string) { - found := false - rows, err := db.Query(`PRAGMA table_info("` + table + `")`) - if err != nil { - return - } - 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 == oldName { - found = true - } - } - rows.Close() - if found { - db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`) - } -} - // --- Types --- const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, @@ -420,40 +226,40 @@ type Department struct { } type Volunteer struct { - ID int `json:"id"` - ParticipantID *int `json:"participant_id,omitempty"` - AttendeeID *int `json:"attendee_id,omitempty"` // deprecated; kept for migration compat - Name string `json:"name"` - PreferredName string `json:"preferred_name"` - Email string `json:"email"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - Ready bool `json:"ready"` - ReadyAt *string `json:"ready_at,omitempty"` - Confirmed bool `json:"confirmed"` - ConfirmedAt *string `json:"confirmed_at,omitempty"` - EmailConfirmed bool `json:"email_confirmed"` - ConfirmationToken *string `json:"-"` - KioskCode *string `json:"kiosk_code,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` -} - -type Participant struct { ID int `json:"id"` - Email string `json:"email"` - PreferredName string `json:"preferred_name"` - TicketName string `json:"ticket_name"` - Phone string `json:"phone"` - Pronouns string `json:"pronouns"` + ParticipantID int `json:"participant_id"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + Ready bool `json:"ready"` + ReadyAt *string `json:"ready_at,omitempty"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *string `json:"confirmed_at,omitempty"` + KioskCode *string `json:"kiosk_code,omitempty"` Note string `json:"note"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` DeletedAt *string `json:"deleted_at,omitempty"` + // Populated via JOIN from participant, not stored on volunteers table: + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + EmailConfirmed bool `json:"email_confirmed"` +} + +type Participant struct { + ID int `json:"id"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + TicketName string `json:"ticket_name"` + Phone string `json:"phone"` + Pronouns string `json:"pronouns"` + Note string `json:"note"` + EmailConfirmed bool `json:"email_confirmed"` + ConfirmationToken *string `json:"-"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Ticket struct { @@ -884,7 +690,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) { // --- Participants --- -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at` +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` func (app *App) listParticipants(search, since string) ([]Participant, error) { var q string @@ -924,8 +730,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) { func (app *App) createParticipant(p Participant) (*Participant, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), + `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, boolInt(p.EmailConfirmed), p.ConfirmationToken, now(), ) if err != nil { return nil, err @@ -980,12 +786,19 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) var result []Participant for rows.Next() { var p Participant + var emailConfirmed int + var confirmationToken sql.NullString if err := rows.Scan( &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, + &emailConfirmed, &confirmationToken, &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, ); err != nil { return nil, err } + p.EmailConfirmed = emailConfirmed == 1 + if confirmationToken.Valid { + p.ConfirmationToken = &confirmationToken.String + } result = append(result, p) } return result, rows.Err() @@ -1217,23 +1030,13 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -// volunteerSelect / volunteerFrom are used together for all volunteer queries. -// Personal fields (name, email, phone, pronouns) come from the joined participant when available, -// falling back to the volunteer's own columns for legacy rows. -const volunteerSelect = `v.id, v.participant_id, v.attendee_id, - COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - COALESCE(NULLIF(p.preferred_name,''), NULLIF(v.preferred_name,''), v.name), - COALESCE(NULLIF(p.email,''), v.email), - COALESCE(NULLIF(p.phone,''), v.phone), - COALESCE(NULLIF(p.pronouns,''), v.pronouns), +const volunteerSelect = `v.id, v.participant_id, + p.preferred_name, p.email, p.phone, p.pronouns, v.department_id, v.is_lead, v.ready, v.ready_at, v.confirmed, v.confirmed_at, - v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note, + p.email_confirmed, v.kiosk_code, v.note, v.created_at, v.updated_at, v.deleted_at` -const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id` - -// volunteerCols is kept for backward-compat references that expect unqualified column names. -const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at` +const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` @@ -1245,15 +1048,15 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (v.name LIKE ? OR v.email LIKE ? OR p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" - args = append(args, s, s, s, s) + args = append(args, s, s) } if deptID != nil { q += ` AND v.department_id = ?` args = append(args, *deptID) } - q += ` ORDER BY COALESCE(NULLIF(p.preferred_name,''), v.name)` + q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) } @@ -1266,15 +1069,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } -func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.attendee_id = ? AND v.deleted_at IS NULL LIMIT 1`, attendeeID) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) @@ -1286,10 +1080,9 @@ func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, erro func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, email_confirmed, confirmation_token, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, - v.DepartmentID, boolInt(v.IsLead), boolInt(v.EmailConfirmed), v.ConfirmationToken, v.Note, now(), + `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?)`, + v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), ) if err != nil { return nil, err @@ -1300,9 +1093,8 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET participant_id=?, attendee_id=?, name=?, preferred_name=?, email=?, phone=?, pronouns=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.ParticipantID, v.AttendeeID, v.Name, v.PreferredName, v.Email, v.Phone, v.Pronouns, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err @@ -1315,8 +1107,6 @@ func (app *App) deleteVolunteer(id int) error { return err } -// markVolunteerReady marks the volunteer as ready and, if linked to an attendee, -// also increments the attendee's checked_in_count. func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( @@ -1327,14 +1117,7 @@ func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { 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 + return app.getVolunteer(id) } func (app *App) confirmVolunteer(id int) (*Volunteer, error) { @@ -1359,34 +1142,23 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var participantID, attendeeID, deptID sql.NullInt64 + var deptID sql.NullInt64 var isLead, ready, confirmed, emailConfirmed int - var confirmationToken, confirmedAt, kioskCode sql.NullString + var confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, - &v.Email, &v.Phone, &v.Pronouns, &deptID, - &isLead, &ready, &v.ReadyAt, + &v.ID, &v.ParticipantID, + &v.Name, &v.Email, &v.Phone, &v.Pronouns, + &deptID, &isLead, &ready, &v.ReadyAt, &confirmed, &confirmedAt, - &emailConfirmed, &confirmationToken, &kioskCode, &v.Note, + &emailConfirmed, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } - if participantID.Valid { - id := int(participantID.Int64) - v.ParticipantID = &id - } - if attendeeID.Valid { - id := int(attendeeID.Int64) - v.AttendeeID = &id - } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } - if confirmationToken.Valid { - v.ConfirmationToken = &confirmationToken.String - } if confirmedAt.Valid { v.ConfirmedAt = &confirmedAt.String } @@ -1404,7 +1176,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(v.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE LOWER(p.email) = LOWER(?) AND v.deleted_at IS NULL LIMIT 1`, email) if err != nil || len(rows) == 0 { return nil, err } @@ -1413,17 +1185,24 @@ func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE p.confirmation_token = ? AND v.deleted_at IS NULL LIMIT 1`, token) if err != nil || len(rows) == 0 { return nil, err } return &rows[0], nil } -func (app *App) confirmVolunteerEmail(id int) error { +func (app *App) confirmParticipantEmail(participantID int) error { _, err := app.db.Exec( - `UPDATE volunteers SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, - now(), id) + `UPDATE participants SET email_confirmed = 1, confirmation_token = NULL, updated_at = ? WHERE id = ?`, + now(), participantID) + return err +} + +func (app *App) setParticipantConfirmationToken(participantID int, token string) error { + _, err := app.db.Exec( + `UPDATE participants SET confirmation_token = ?, updated_at = ? WHERE id = ?`, + token, now(), participantID) return err } @@ -1442,11 +1221,10 @@ func (app *App) assignKioskCode(id int, code string) error { return err } -// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code. func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { return queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) } func (app *App) generateVolunteerKioskCode() (string, error) { @@ -1658,7 +1436,7 @@ var errShiftFull = fmt.Errorf("shift is full") func (app *App) unassignShift(volunteerID, shiftID int) error { _, err := app.db.Exec( - `UPDATE volunteer_shifts SET deleted_at = ?, updated_at = ? WHERE volunteer_id=? AND shift_id=?`, + `UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=?`, now(), now(), volunteerID, shiftID, ) return err diff --git a/db_test.go b/db_test.go index 0d453bb..c9e8b68 100644 --- a/db_test.go +++ b/db_test.go @@ -197,7 +197,8 @@ func TestAssignAndUnassignShift(t *testing.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: "Helena", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) if err := app.assignShift(v.ID, s.ID); err != nil { t.Fatal(err) @@ -221,7 +222,8 @@ func TestCheckShiftConflict(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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"}) @@ -250,7 +252,8 @@ func TestCheckShiftConflictMidnight(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Sound"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, 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"}) diff --git a/frontend/src/db.js b/frontend/src/db.js index a09f332..7e96bbc 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -46,6 +46,11 @@ db.version(4).stores({ volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at', }) +db.version(5).stores({ + volunteers: 'id, participant_id, department_id, deleted_at', + participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', +}) + export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/handle_kiosk_test.go b/handle_kiosk_test.go index e385ad3..2bac7cf 100644 --- a/handle_kiosk_test.go +++ b/handle_kiosk_test.go @@ -16,7 +16,7 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) { // Create volunteer with a kiosk_code directly on the volunteer record p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"}) - v, _ := app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: &p.ID, DepartmentID: &deptID}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) token, _ := app.generateVolunteerKioskCode() app.assignKioskCode(v.ID, token) @@ -132,7 +132,8 @@ func TestKioskClaimFull(t *testing.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}) + otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"}) + other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID}) app.assignShift(other.ID, 2) // fills the capacity-1 shift req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil) diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 8c629b1..940164e 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -55,7 +55,8 @@ func TestShiftAssignVolunteer(t *testing.T) { 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: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{ @@ -86,7 +87,8 @@ func TestShiftAssignConflict(t *testing.T) { 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: "Titania", DepartmentID: &deptID}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"}) + app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Assign to first shift app.assignShift(1, 1) diff --git a/handle_signup.go b/handle_signup.go index 77a7c63..1f0fe2e 100644 --- a/handle_signup.go +++ b/handle_signup.go @@ -89,17 +89,12 @@ func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) { writeError(w, "internal error", http.StatusInternalServerError) return } + app.setParticipantConfirmationToken(participant.ID, confirmToken) vol := Volunteer{ - ParticipantID: &participant.ID, - Name: body.PreferredName, - PreferredName: body.PreferredName, - Email: body.Email, - Phone: body.Phone, - Pronouns: body.Pronouns, - DepartmentID: body.DepartmentID, - Note: body.Note, - ConfirmationToken: &confirmToken, + ParticipantID: participant.ID, + DepartmentID: body.DepartmentID, + Note: body.Note, } if _, err := app.createVolunteer(vol); err != nil { @@ -136,7 +131,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { return } - if err := app.confirmVolunteerEmail(vol.ID); err != nil { + if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } @@ -153,7 +148,7 @@ func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code) response["kiosk_link"] = kioskLink go func() { - if err := app.sendShiftSignupEmail(vol.Email, vol.PreferredName, kioskLink); err != nil { + if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil { log.Printf("shift signup email to %s failed: %v", vol.Email, err) } }() @@ -198,7 +193,7 @@ func (app *App) openShiftSignups() { // Email all email-confirmed volunteers that now have a kiosk code. confirmed, _ := queryVolunteers(app.db, ` SELECT `+volunteerSelect+` `+volunteerFrom+` - WHERE v.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`) baseURL := app.resolveBaseURL() sent := 0 @@ -207,11 +202,7 @@ func (app *App) openShiftSignups() { continue } kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode) - name := v.PreferredName - if name == "" { - name = v.Name - } - if err := app.sendShiftSignupEmail(v.Email, name, kioskLink); err == nil { + if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil { sent++ } else { log.Printf("shift signup email to %s failed: %v", v.Email, err) diff --git a/handle_signup_test.go b/handle_signup_test.go index 2b63c16..62f59d1 100644 --- a/handle_signup_test.go +++ b/handle_signup_test.go @@ -58,27 +58,27 @@ func TestPublicSignup(t *testing.T) { if err != nil || vol == nil { t.Fatal("volunteer not created") } - if vol.PreferredName != "Titania" { - t.Errorf("preferred_name = %q, want Titania", vol.PreferredName) + if vol.Name != "Titania" { + t.Errorf("name = %q, want Titania", vol.Name) } if vol.Pronouns != "she/they" { t.Errorf("pronouns = %q, want she/they", vol.Pronouns) } - if vol.ConfirmationToken == nil || *vol.ConfirmationToken == "" { - t.Error("expected confirmation token to be set") - } if vol.EmailConfirmed { t.Error("should not be confirmed yet") } // Participant should be auto-created and linked - if vol.ParticipantID == nil { + if vol.ParticipantID == 0 { t.Fatal("expected participant to be linked") } - p, _ := app.getParticipant(*vol.ParticipantID) + p, _ := app.getParticipant(vol.ParticipantID) if p == nil { t.Fatal("linked participant not found") } + if p.ConfirmationToken == nil || *p.ConfirmationToken == "" { + t.Error("expected confirmation token on participant") + } if p.Email != "titania@example.com" { t.Errorf("participant email = %q, want titania@example.com", p.Email) } @@ -105,8 +105,8 @@ func TestPublicSignupAutoMatchParticipant(t *testing.T) { if vol == nil { t.Fatal("volunteer not created") } - if vol.ParticipantID == nil || *vol.ParticipantID != existing.ID { - t.Errorf("expected volunteer linked to existing participant %d, got %v", existing.ID, vol.ParticipantID) + if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID { + t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID) } } @@ -200,12 +200,8 @@ func TestConfirmEmail(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -217,12 +213,13 @@ func TestConfirmEmail(t *testing.T) { t.Errorf("expected confirmed, got %v", result["status"]) } - // Verify volunteer is confirmed + // Verify participant is email confirmed vol, _ := app.getVolunteerByEmail("titania@example.com") if vol == nil || !vol.EmailConfirmed { - t.Error("volunteer should be email confirmed") + t.Error("volunteer should show email confirmed via participant") } - if vol.ConfirmationToken != nil { + updatedP, _ := app.getParticipant(p.ID) + if updatedP.ConfirmationToken != nil { t.Error("confirmation token should be cleared after confirmation") } } @@ -247,12 +244,8 @@ func TestConfirmEmailAlreadyConfirmed(t *testing.T) { mux := testMux(app) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ConfirmationToken: &token, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm first time w := httptest.NewRecorder() @@ -277,15 +270,9 @@ func TestConfirmEmailWithSignupsOpen(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) @@ -327,10 +314,10 @@ func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) { } vol, _ := app.getVolunteerByEmail("titania@example.com") - if vol == nil || vol.ParticipantID == nil { + if vol == nil || vol.ParticipantID == 0 { t.Fatal("volunteer/participant not created") } - p, _ := app.getParticipant(*vol.ParticipantID) + p, _ := app.getParticipant(vol.ParticipantID) if p == nil { t.Fatal("participant not found") } @@ -349,15 +336,9 @@ func TestConfirmEmailAssignsKioskCode(t *testing.T) { app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`) app.baseURL = "https://example.com" - participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com"}) token := "abc123def456" - app.createVolunteer(Volunteer{ - Name: "Titania", - PreferredName: "Titania", - Email: "titania@example.com", - ParticipantID: &participant.ID, - ConfirmationToken: &token, - }) + participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token}) + app.createVolunteer(Volunteer{ParticipantID: participant.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token})) diff --git a/handle_volunteers.go b/handle_volunteers.go index 0a9fec0..5d086ad 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -35,54 +35,62 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { var body struct { - Volunteer - TicketName string `json:"ticket_name"` + Name string `json:"name"` + TicketName string `json:"ticket_name"` + Email string `json:"email"` + DepartmentID *int `json:"department_id"` + IsLead bool `json:"is_lead"` + Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, "invalid request", http.StatusBadRequest) return } - v := body.Volunteer - if v.Name == "" { + if body.Name == "" { writeError(w, "name is required", http.StatusBadRequest) return } - if v.Email == "" { + if body.Email == "" { writeError(w, "email is required", http.StatusBadRequest) return } claims := claimsFromContext(r) if claims.Role == "colead" { - if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } } - if v.Email != "" && v.ParticipantID == nil { - p, _ := app.getParticipantByEmail(v.Email) - if p == nil { - p, _ = app.createParticipant(Participant{PreferredName: v.Name, Email: v.Email, TicketName: body.TicketName}) - } else if body.TicketName != "" && p.TicketName == "" { - app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) - } - if p != nil { - v.ParticipantID = &p.ID - } + p, _ := app.getParticipantByEmail(body.Email) + if p == nil { + p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName}) + } else if body.TicketName != "" && p.TicketName == "" { + app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID) + } + if p == nil { + writeError(w, "failed to create participant", http.StatusInternalServerError) + return } confirmToken, err := generateConfirmationToken() if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return } - v.ConfirmationToken = &confirmToken + app.setParticipantConfirmationToken(p.ID, confirmToken) + v := Volunteer{ + ParticipantID: p.ID, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } created, err := app.createVolunteer(v) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } go func() { - if err := app.sendConfirmationEmail(v.Email, v.Name, confirmToken); err != nil { - log.Printf("confirmation email to %s failed: %v", v.Email, err) + if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil { + log.Printf("confirmation email to %s failed: %v", body.Email, err) } }() w.WriteHeader(http.StatusCreated) @@ -109,13 +117,13 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { 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 + var body struct { + DepartmentID *int `json:"department_id"` + IsLead bool `json:"is_lead"` + Note string `json:"note"` } - if v.Name == "" { - writeError(w, "name is required", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) return } claims := claimsFromContext(r) @@ -126,12 +134,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } } - v.ID = id + v := Volunteer{ + ID: id, + DepartmentID: body.DepartmentID, + IsLead: body.IsLead, + Note: body.Note, + } if err := app.updateVolunteer(v); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - if v.IsLead { app.confirmVolunteer(id) } diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index e10815c..ab51b9b 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -14,10 +14,8 @@ func TestConfirmVolunteer(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Titania", Email: "titania@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -46,7 +44,8 @@ func TestConfirmVolunteerIdempotent(t *testing.T) { admin := testAdminUser(t, app) tok := testToken(t, app, admin) - v, _ := app.createVolunteer(Volunteer{Name: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Confirm twice — second should be a no-op, not an error. w := httptest.NewRecorder() @@ -70,7 +69,8 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) tok := testToken(t, app, ticketing) - v, _ := app.createVolunteer(Volunteer{Name: "Helena", EmailConfirmed: true}) + p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) @@ -86,12 +86,13 @@ func TestUpdateVolunteerDepartment(t *testing.T) { tok := testToken(t, app, admin) dept, _ := app.createDepartment(Department{Name: "Gate"}) - v, _ := app.createVolunteer(Volunteer{Name: "Hermia"}) + p, _ := app.createParticipant(Participant{PreferredName: "Hermia"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) // Assign department via update. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "name": "Hermia", "department_id": dept.ID, + "department_id": dept.ID, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -111,10 +112,8 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { dept, _ := app.createDepartment(Department{Name: "Build"}) deptID := dept.ID - v, _ := app.createVolunteer(Volunteer{ - Name: "Lysander", Email: "lys@test.com", - DepartmentID: &deptID, EmailConfirmed: true, - }) + p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID}) // Verify not confirmed before update. got, _ := app.getVolunteer(v.ID) @@ -125,7 +124,7 @@ func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) { // Update is_lead=true should auto-confirm. w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ - "name": "Lysander", "department_id": deptID, "is_lead": true, + "department_id": deptID, "is_lead": true, }, tok)) if w.Code != 200 { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) From e640bf8bedbddd09341c79f381a4d4a268d00e15 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 13:01:35 -0500 Subject: [PATCH 42/54] Removed `v` prefix from git tag recipes --- Makefile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 30bca30..a8de0d3 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: build frontend-build dev clean test patch minor major LAST_TAG := $(shell git tag --sort=-v:refname | head -1) -MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1) -MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2) -PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3) +MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1) +MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2) +PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3) build: frontend-build CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . @@ -25,13 +25,13 @@ clean: rm -rf frontend/dist patch: - git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) - @echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" minor: - git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0 - @echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" major: - git tag v$(shell echo $$(($(MAJOR)+1))).0.0 - @echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0" + git tag $(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0" From 1eb6a99ff6746d03a41d88ce59198130902a2ad8 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 14:08:00 -0500 Subject: [PATCH 43/54] Refactored user/volunteer/participant identity. --- auth.go | 30 +-- auth_test.go | 15 +- db.go | 290 +++++++++++++++++++----- db_test.go | 2 +- frontend/src/App.svelte | 9 +- frontend/src/api.js | 4 +- frontend/src/api.test.js | 4 +- frontend/src/components/Nav.svelte | 7 +- frontend/src/db.js | 2 + frontend/src/db.test.js | 4 +- frontend/src/pages/Dashboard.svelte | 23 +- frontend/src/pages/Departments.svelte | 7 +- frontend/src/pages/Login.svelte | 8 +- frontend/src/pages/Participants.svelte | 5 +- frontend/src/pages/ScheduleBoard.svelte | 7 +- frontend/src/pages/Users.svelte | 86 ++++--- frontend/src/pages/Volunteers.svelte | 9 +- handle_attendees_test.go | 12 +- handle_auth.go | 8 +- handle_participants.go | 2 +- handle_settings_test.go | 4 +- handle_shifts.go | 6 +- handle_sync_test.go | 25 +- handle_users.go | 36 +-- handle_volunteers.go | 8 +- handle_volunteers_test.go | 8 +- main.go | 102 ++++----- testutil_test.go | 11 +- 28 files changed, 469 insertions(+), 265 deletions(-) diff --git a/auth.go b/auth.go index c2d11af..0cc812a 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - UserID int `json:"uid"` - Username string `json:"sub"` - Role string `json:"role"` - DeptIDs []int `json:"dept_ids,omitempty"` + ParticipantID int `json:"pid"` + Email string `json:"sub"` + Roles []string `json:"roles"` + DeptIDs []int `json:"dept_ids,omitempty"` jwt.RegisteredClaims } @@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -func (app *App) signToken(u *User) (string, error) { +func (app *App) signToken(s *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - UserID: u.ID, - Username: u.Username, - Role: u.Role, - DeptIDs: u.DepartmentIDs, + ParticipantID: s.ID, + Email: s.Email, + Roles: s.Roles, + DeptIDs: s.DepartmentIDs, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler writeError(w, "unauthorized", http.StatusUnauthorized) return } - if len(roles) > 0 && !hasRole(claims.Role, roles) { + if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,10 +97,12 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -func hasRole(role string, allowed []string) bool { - for _, r := range allowed { - if r == role { - return true +func hasAnyRole(roles []string, allowed []string) bool { + for _, r := range roles { + for _, a := range allowed { + if r == a { + return true + } } } return false diff --git a/auth_test.go b/auth_test.go index f611bc1..602c6cf 100644 --- a/auth_test.go +++ b/auth_test.go @@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": admin.Username, + "email": admin.Email, "password": "admin123", }) w := httptest.NewRecorder() @@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) { t.Error("missing token in response") } user, ok := result["user"].(map[string]any) - if !ok || user["username"] != "admin" { + if !ok || user["email"] != "oberon@athens.example" { t.Errorf("user = %v", result["user"]) } } @@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": "admin", + "email": "oberon@athens.example", "password": "wrong", }) w := httptest.NewRecorder() @@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) { mux := testMux(app) req := testRequest("POST", "/api/login", map[string]string{ - "username": "nobody", + "email": "nobody@test.com", "password": "test", }) w := httptest.NewRecorder() @@ -94,8 +94,7 @@ 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", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) req := testAuthRequest("GET", "/api/users", nil, token) @@ -121,7 +120,7 @@ func TestMeEndpoint(t *testing.T) { t.Fatalf("status = %d", w.Code) } result := parseJSON(t, w) - if result["username"] != "admin" { - t.Errorf("username = %v", result["username"]) + if result["email"] != "oberon@athens.example" { + t.Errorf("email = %v", result["email"]) } } diff --git a/db.go b/db.go index bda44ed..99b335c 100644 --- a/db.go +++ b/db.go @@ -40,20 +40,6 @@ func migrate(db *sql.DB) error { 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','ticketing','staffing','colead','gatekeeper')), - 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, @@ -75,7 +61,7 @@ func migrate(db *sql.DB) error { 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), + checked_in_by INTEGER REFERENCES participants(id), note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -154,7 +140,7 @@ func migrate(db *sql.DB) error { order_id TEXT NOT NULL DEFAULT '', code TEXT UNIQUE, checked_in_at TEXT, - checked_in_by INTEGER REFERENCES users(id), + checked_in_by INTEGER REFERENCES participants(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -162,10 +148,99 @@ func migrate(db *sql.DB) error { CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_source_external ON tickets(source, external_id) WHERE external_id != '' AND deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS participant_roles ( + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK(role IN ('admin','staffing','colead','gatekeeper')), + PRIMARY KEY (participant_id, role) + ); + + CREATE TABLE IF NOT EXISTS participant_departments ( + participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + PRIMARY KEY (participant_id, department_id) + ); `) if err != nil { return err } + if err := migrateAuth(db); err != nil { + return err + } + return nil +} + +func migrateAuth(db *sql.DB) error { + // Add auth columns to participants (idempotent — ignore "duplicate column" errors). + db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) + db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) + + // Migrate users → participants if the old users table exists. + var hasUsers int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { + return nil + } + + // Collect all users first (single connection — can't query and exec concurrently). + type oldUser struct { + id int + name string + hash string + role string + } + rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) + if err != nil { + return nil + } + var users []oldUser + for rows.Next() { + var u oldUser + if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { + continue + } + if u.role == "ticketing" { + u.role = "admin" + } + users = append(users, u) + } + rows.Close() + + // Collect department assignments. + type deptAssign struct { + userID int + deptID int + } + deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) + var deptAssigns []deptAssign + if err == nil { + for deptRows.Next() { + var da deptAssign + deptRows.Scan(&da.userID, &da.deptID) + deptAssigns = append(deptAssigns, da) + } + deptRows.Close() + } + + // Now insert with the connection free. + for _, u := range users { + res, err := db.Exec( + `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, + u.name, u.hash, now(), + ) + if err != nil { + continue + } + pid, _ := res.LastInsertId() + db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) + for _, da := range deptAssigns { + if da.userID == u.id { + db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) + } + } + } + + db.Exec(`DROP TABLE IF EXISTS user_departments`) + db.Exec(`DROP TABLE IF EXISTS users`) return nil } @@ -190,11 +265,12 @@ type Event struct { } 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"` + ID int `json:"id"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Roles []string `json:"roles"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` } type Attendee struct { @@ -325,11 +401,45 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Users --- +// --- Staff (participants with login_enabled) --- -func (app *App) getUserDeptIDs(userID int) ([]int, error) { +func (app *App) getParticipantRoles(participantID int) ([]string, error) { rows, err := app.db.Query( - `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, + `SELECT role FROM participant_roles WHERE participant_id = ? ORDER BY role`, participantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var roles []string + for rows.Next() { + var r string + rows.Scan(&r) + roles = append(roles, r) + } + if roles == nil { + roles = []string{} + } + return roles, rows.Err() +} + +func (app *App) setParticipantRoles(participantID int, roles []string) error { + if _, err := app.db.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, participantID); err != nil { + return err + } + for _, role := range roles { + if _, err := app.db.Exec( + `INSERT INTO participant_roles (participant_id, role) VALUES (?, ?)`, participantID, role, + ); err != nil { + return err + } + } + return nil +} + +func (app *App) getUserDeptIDs(participantID int) ([]int, error) { + rows, err := app.db.Query( + `SELECT department_id FROM participant_departments WHERE participant_id = ? ORDER BY department_id`, participantID, ) if err != nil { return nil, err @@ -347,14 +457,13 @@ func (app *App) getUserDeptIDs(userID int) ([]int, error) { 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 { +func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { + if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, + `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, ); err != nil { return err } @@ -362,98 +471,157 @@ func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { return nil } -func (app *App) getUserByUsername(username string) (*User, string, error) { - var u User - var hash string +func (app *App) getLoginParticipant(email string) (*User, string, error) { + var s User + var hash sql.NullString 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) + `SELECT id, email, preferred_name, password_hash, created_at + FROM participants WHERE LOWER(email) = LOWER(?) AND login_enabled = 1 AND deleted_at IS NULL`, email, + ).Scan(&s.ID, &s.Email, &s.PreferredName, &hash, &s.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 + var hashStr string + if hash.Valid { + hashStr = hash.String + } + s.Roles, _ = app.getParticipantRoles(s.ID) + s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) + return &s, hashStr, nil } -func (app *App) getUserByID(id int) (*User, error) { - var u User +func (app *App) getUser(id int) (*User, error) { + var s 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) + `SELECT id, email, preferred_name, created_at + FROM participants WHERE id = ? AND login_enabled = 1 AND deleted_at IS NULL`, id, + ).Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) - return &u, err + s.Roles, _ = app.getParticipantRoles(s.ID) + s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) + return &s, nil } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, username, role, created_at FROM users ORDER BY username`, + `SELECT id, email, preferred_name, created_at + FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, ) if err != nil { return nil, err } defer rows.Close() - var users []User + var staff []User for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { + var s User + if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { return nil, err } - u.DepartmentIDs = []int{} - users = append(users, u) + s.Roles = []string{} + s.DepartmentIDs = []int{} + staff = append(staff, s) } if err := rows.Err(); err != nil { return nil, err } - for i := range users { - users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) + for i := range staff { + staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) + staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) } - return users, nil + return staff, nil } -func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { +func (app *App) createUser(email, preferredName, hash string, roles []string, deptIDs []int) (*User, error) { + // Find or create participant by email. + p, err := app.getParticipantByEmail(email) + if err != nil { + return nil, err + } + if p != nil { + // Participant exists — promote to staff. + if _, err := app.db.Exec( + `UPDATE participants SET password_hash = ?, login_enabled = 1, updated_at = ? WHERE id = ?`, + hash, now(), p.ID, + ); err != nil { + return nil, err + } + if err := app.setParticipantRoles(p.ID, roles); err != nil { + return nil, err + } + if err := app.setUserDeptIDs(p.ID, deptIDs); err != nil { + return nil, err + } + return app.getUser(p.ID) + } + // Create new participant with auth. res, err := app.db.Exec( - `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, - username, hash, role, + `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) + VALUES (?, ?, ?, 1, ?)`, + strings.ToLower(email), preferredName, hash, now(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() + if err := app.setParticipantRoles(int(id), roles); err != nil { + return nil, err + } if err := app.setUserDeptIDs(int(id), deptIDs); err != nil { return nil, err } - return app.getUserByID(int(id)) + return app.getUser(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 { +func (app *App) updateUserRoles(id int, roles []string, deptIDs []int) error { + var enabled int + err := app.db.QueryRow(`SELECT login_enabled FROM participants WHERE id = ? AND deleted_at IS NULL`, id).Scan(&enabled) + if err != nil || enabled != 1 { + return fmt.Errorf("participant not found or not a staff member") + } + if err := app.setParticipantRoles(id, roles); 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) + _, err := app.db.Exec( + `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), 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) removeUser(id int) error { + tx, err := app.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM participant_roles WHERE participant_id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec( + `UPDATE participants SET login_enabled = 0, password_hash = NULL, updated_at = ? WHERE id = ?`, now(), id, + ); err != nil { + return err + } + return tx.Commit() } func (app *App) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) return n, err } diff --git a/db_test.go b/db_test.go index c9e8b68..edb2b6d 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( 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"} + tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7ad6bc9..52c6741 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -83,7 +83,8 @@ } const path = $derived(route || '/') - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -103,8 +104,8 @@ {:else if !session} -{:else if role === 'gatekeeper'} - +{:else if roles.length === 1 && roles[0] === 'gatekeeper'} + {:else}
@@ -121,7 +122,7 @@ Turnpike {#if path === '/' || path === ''} - {#if role === 'colead'} + {#if roles.length === 1 && roles[0] === 'colead'} {:else} diff --git a/frontend/src/api.js b/frontend/src/api.js index 686faa8..1b3d537 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) { } export const api = { - login: (username, password) => - apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + login: (email, password) => + apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }), logout: () => apiFetch('/api/logout', { method: 'POST' }), me: () => apiJSON('/api/me'), event: { diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js index 974dd32..a725f32 100644 --- a/frontend/src/api.test.js +++ b/frontend/src/api.test.js @@ -64,11 +64,11 @@ describe('apiJSON', () => { describe('api methods', () => { it('login calls correct endpoint', async () => { const f = mockFetch({ token: 'tok', user: { id: 1 } }) - await api.login('admin', 'pass') + await api.login('admin@example.com', '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' }) + expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' }) }) it('participants.list calls correct endpoint', async () => { diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index 545e171..61015f5 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -3,17 +3,18 @@ let { session, active, onLogout, navigate, open = false } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const iconProps = { size: 18, strokeWidth: 1.75 } const links = $derived.by(() => { - if (role === 'colead') return [ + if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [ { href: '/', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, { href: '/departments', label: 'Departments', icon: Hexagon }, ] - if (role === 'staffing') return [ + if (!hasRole('admin') && hasRole('staffing')) return [ { href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/schedule', label: 'Schedule', icon: CalendarDays }, { href: '/volunteers', label: 'Volunteers', icon: Heart }, diff --git a/frontend/src/db.js b/frontend/src/db.js index 7e96bbc..cbc0d38 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,6 +51,8 @@ db.version(5).stores({ participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', }) +db.version(6).stores({}).upgrade(tx => tx.table('session').clear()) + export async function getLastSync() { const m = await db.meta.get('last_sync') return m?.value ?? '' diff --git a/frontend/src/db.test.js b/frontend/src/db.test.js index 282b6fc..081ce1a 100644 --- a/frontend/src/db.test.js +++ b/frontend/src/db.test.js @@ -22,10 +22,10 @@ describe('session', () => { }) it('saves and retrieves session', async () => { - await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' }) + await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] }) const s = await getSession() expect(s.token).toBe('tok123') - expect(s.user.username).toBe('admin') + expect(s.user.email).toBe('admin@example.com') }) it('clears session and meta', async () => { diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index e5a26de..0f6decc 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -4,11 +4,12 @@ let { session } = $props() - const role = $derived(session?.user?.role ?? '') + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } const myDeptIDs = $derived(session?.user?.department_ids ?? []) - const isTicketing = $derived(['admin', 'ticketing'].includes(role)) - const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const isColead = $derived(role === 'colead') + const isAdmin = $derived(hasRole('admin')) + const isStaffing = $derived(hasRole('admin', 'staffing')) + const isColead = $derived(hasRole('colead')) const event = liveQuery(() => db.event.get(1)) const allTickets = liveQuery(() => db.tickets.toArray()) @@ -76,8 +77,8 @@

{/if} - - {#if isTicketing} + + {#if isAdmin}

Ticket Check-in

@@ -105,7 +106,7 @@ {/if} {/if} - + {#if isStaffing || isColead}

{isColead ? 'My Volunteers' : 'Volunteers'}

@@ -124,7 +125,7 @@
{/if} - + {#if isStaffing || isColead}

{isColead ? 'My Shifts' : 'Shift Coverage'}

@@ -144,7 +145,7 @@ {/if} - {#if isTicketing} + {#if isAdmin}
Import CSV Manage Participants @@ -158,8 +159,8 @@ {/if}

- Welcome, {session?.user?.username} - · {session?.user?.role} + Welcome, {session?.user?.preferred_name} + · {#each roles as r}{r}{/each}

diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index c2cf82b..26164eb 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -18,9 +18,10 @@ let editDesc = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role)) - const canDelete = $derived(['admin', 'ticketing'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canCreate = $derived(hasRole('admin', 'staffing')) + const canDelete = $derived(hasRole('admin')) const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray() diff --git a/frontend/src/pages/Login.svelte b/frontend/src/pages/Login.svelte index de4f6af..1512af7 100644 --- a/frontend/src/pages/Login.svelte +++ b/frontend/src/pages/Login.svelte @@ -4,7 +4,7 @@ let { onlogin } = $props() - let username = $state('') + let email = $state('') let password = $state('') let error = $state('') let loading = $state(false) @@ -14,7 +14,7 @@ error = '' loading = true try { - const { token, user } = await api.login(username, password) + const { token, user } = await api.login(email, password) await saveSession(token, user) onlogin({ token, user }) } catch (err) { @@ -34,8 +34,8 @@ {/if}
- - + +
diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 2867958..fa850a3 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -40,8 +40,9 @@ let newTicketType = $state('') let newTicketExtId = $state('') - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin')) const allParticipants = liveQuery(() => db.participants.toArray()) const allTickets = liveQuery(() => db.tickets.toArray()) diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 6755588..922a0b0 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -25,8 +25,9 @@ let assignVolID = $state(0) let assigning = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin', 'staffing', 'colead')) const myDeptIDs = $derived(session?.user?.department_ids ?? []) const allDepts = liveQuery(() => @@ -54,7 +55,7 @@ // Departments visible to this user const visibleDepts = $derived.by(() => { const depts = $allDepts ?? [] - if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id)) + if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id)) return depts }) diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index ee6b18a..328cc66 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -12,13 +12,14 @@ let showAdd = $state(false) let adding = $state(false) - let newUsername = $state('') + let newEmail = $state('') + let newName = $state('') let newPassword = $state('') - let newRole = $state('gate') + let newRoles = $state([]) let newDeptIDs = $state([]) let editID = $state(null) - let editRole = $state('') + let editRoles = $state([]) let editDeptIDs = $state([]) let editPassword = $state('') let saving = $state(false) @@ -28,7 +29,7 @@ .then(arr => arr.sort((a, b) => a.name.localeCompare(b.name))) ) - const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper'] + const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper'] const me = $derived(session?.user?.id) @@ -51,15 +52,16 @@ error = '' try { const u = await api.users.create({ - username: newUsername, + email: newEmail, + preferred_name: newName, password: newPassword, - role: newRole, + roles: newRoles, department_ids: newDeptIDs, }) users = [...users, u] showAdd = false - newUsername = newPassword = '' - newRole = 'gate' + newEmail = newName = newPassword = '' + newRoles = [] newDeptIDs = [] } catch (err) { error = err.message @@ -70,7 +72,7 @@ function startEdit(u) { editID = u.id - editRole = u.role + editRoles = [...(u.roles || [])] editDeptIDs = [...(u.department_ids || [])] editPassword = '' } @@ -83,7 +85,7 @@ saving = true error = '' try { - const payload = { role: editRole, department_ids: editDeptIDs } + const payload = { roles: editRoles, department_ids: editDeptIDs } if (editPassword) payload.password = editPassword const updated = await api.users.update(u.id, payload) users = users.map(x => x.id === u.id ? updated : x) @@ -96,7 +98,7 @@ } async function deleteUser(u) { - if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return + if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return try { await api.users.delete(u.id) users = users.filter(x => x.id !== u.id) @@ -105,7 +107,7 @@ } } - function toggleDept(id, list) { + function toggleItem(id, list) { const idx = list.indexOf(id) if (idx === -1) return [...list, id] return list.filter(x => x !== id) @@ -117,7 +119,7 @@ } function roleLabel(r) { - return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r + return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r } @@ -132,7 +134,6 @@

Roles: admin — full access · - ticketing — participants, tickets, import · staffing — volunteers, shifts, departments · colead — manage assigned departments only · gatekeeper — check-in only @@ -150,20 +151,29 @@

- - + + +
+
+ +
-
- - +
+
+ Roles +
+ {#each availableRoles as r} + + {/each}
{#if ($allDepts ?? []).length > 0} @@ -174,7 +184,7 @@ @@ -204,8 +214,8 @@ - - + + @@ -214,13 +224,18 @@ {#each users as u (u.id)} {#if editID === u.id} - + - + diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index e90bf80..79681f8 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -25,14 +25,15 @@ let editNote = $state('') let saving = $state(false) - const role = $derived(session?.user?.role ?? '') - const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role)) - const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role)) + const roles = $derived(session?.user?.roles ?? []) + function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } + const canManage = $derived(hasRole('admin', 'staffing', 'colead')) + const canConfirm = $derived(hasRole('admin', 'staffing', 'colead')) const myDeptIDs = $derived(session?.user?.department_ids ?? []) let deptInitialized = $state(false) $effect(() => { - if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) { + if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) { filterDept = String(myDeptIDs[0]) deptInitialized = true } diff --git a/handle_attendees_test.go b/handle_attendees_test.go index c5e6adb..7dd7ff8 100644 --- a/handle_attendees_test.go +++ b/handle_attendees_test.go @@ -31,8 +31,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { } list := parseJSON(t, w) participants := list["participants"].([]any) - if len(participants) != 1 { - t.Errorf("list: got %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("list: got %d, want 2", len(participants)) } // Delete @@ -48,8 +48,8 @@ func TestParticipantsListCreateDelete(t *testing.T) { w = httptest.NewRecorder() mux.ServeHTTP(w, req) list = parseJSON(t, w) - if ps, ok := list["participants"].([]any); ok && len(ps) != 0 { - t.Errorf("after delete: got %d, want 0", len(ps)) + if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains + t.Errorf("after delete: got %d, want 1", len(ps)) } } @@ -77,7 +77,7 @@ func TestCheckInTicketHandler(t *testing.T) { func TestGatekeeperRoleCanCheckIn(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -94,7 +94,7 @@ func TestGatekeeperRoleCanCheckIn(t *testing.T) { func TestGatekeeperRoleCannotDelete(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_auth.go b/handle_auth.go index 282bd85..d75483b 100644 --- a/handle_auth.go +++ b/handle_auth.go @@ -7,7 +7,7 @@ import ( func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { var body struct { - Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) { return } - user, hash, err := app.getUserByUsername(body.Username) + user, hash, err := app.getLoginParticipant(body.Email) if err != nil { writeError(w, "internal error", http.StatusInternalServerError) return @@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) { func (app *App) handleMe(w http.ResponseWriter, r *http.Request) { claims := claimsFromContext(r) - user, err := app.getUserByID(claims.UserID) + user, err := app.getUser(claims.ParticipantID) if err != nil || user == nil { - writeError(w, "not found", http.StatusNotFound) + writeError(w, "unauthorized", http.StatusUnauthorized) return } writeJSON(w, user) diff --git a/handle_participants.go b/handle_participants.go index 6277824..52624d5 100644 --- a/handle_participants.go +++ b/handle_participants.go @@ -169,7 +169,7 @@ func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - tk, err := app.checkInTicket(id, claims.UserID) + tk, err := app.checkInTicket(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_settings_test.go b/handle_settings_test.go index cbc53fb..16ef59f 100644 --- a/handle_settings_test.go +++ b/handle_settings_test.go @@ -85,7 +85,7 @@ func TestResetTickets(t *testing.T) { func TestResetTicketsRequiresAdmin(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gate1", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) @@ -131,7 +131,7 @@ func TestResetDepartmentsCascadesShifts(t *testing.T) { func TestSettingsNonAdminRejected(t *testing.T) { app := testApp(t) - gate := testUserWithRole(t, app, "gateuser", "gatekeeper", []int{}) + gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{}) token := testToken(t, app, gate) mux := testMux(app) diff --git a/handle_shifts.go b/handle_shifts.go index a0ceb41..67312c5 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -17,7 +17,7 @@ func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -40,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" && !inSlice(s.DepartmentID, claims.DeptIDs) { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) diff --git a/handle_sync_test.go b/handle_sync_test.go index e4aa2af..000bc60 100644 --- a/handle_sync_test.go +++ b/handle_sync_test.go @@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) - app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) dept, _ := app.createDepartment(Department{Name: "Gate"}) deptID := dept.ID - app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID}) + app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, 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) @@ -32,8 +32,8 @@ func TestSyncPullFull(t *testing.T) { t.Error("missing server_time") } participants := result["participants"].([]any) - if len(participants) != 1 { - t.Errorf("participants = %d, want 1", len(participants)) + if len(participants) != 2 { // admin + Titania + t.Errorf("participants = %d, want 2", len(participants)) } depts := result["departments"].([]any) if len(depts) != 1 { @@ -47,14 +47,16 @@ func TestSyncPullIncremental(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) + // Backdate admin participant so it falls before the "since" cutoff. + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) + p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - // Backdate Titania so she falls before the "since" cutoff app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID) since := "2026-01-01T12:00:00Z" - // Oberon created with default updated_at (now), which is after our since - app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"}) + // Lysander created with default updated_at (now), which is after our since + app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"}) req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token) w := httptest.NewRecorder() @@ -62,14 +64,13 @@ func TestSyncPullIncremental(t *testing.T) { result := parseJSON(t, w) participants := result["participants"].([]any) - // Should only include Oberon (created after `since`) if len(participants) != 1 { t.Errorf("incremental: got %d participants, want 1", len(participants)) } if len(participants) == 1 { p := participants[0].(map[string]any) - if p["preferred_name"] != "Oberon" { - t.Errorf("preferred_name = %v, want Oberon", p["preferred_name"]) + if p["preferred_name"] != "Lysander" { + t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"]) } } } @@ -80,8 +81,10 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) { token := testToken(t, app, admin) mux := testMux(app) + // Backdate admin participant. + app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID) + p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"}) - // Backdate Titania's creation so the since cutoff is between creation and deletion app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID) since := "2026-01-01T12:00:00Z" diff --git a/handle_users.go b/handle_users.go index 386e5b0..4de6109 100644 --- a/handle_users.go +++ b/handle_users.go @@ -17,17 +17,18 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) { 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"` + Email string `json:"email"` + PreferredName string `json:"preferred_name"` + Password string `json:"password"` + Roles []string `json:"roles"` + 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) + if body.Email == "" || body.Password == "" || len(body.Roles) == 0 { + writeError(w, "email, password, and at least one role are required", http.StatusBadRequest) return } hash, err := hashPassword(body.Password) @@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs) + user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -53,10 +54,15 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + target, _ := app.getUser(id) + if target == nil { + writeError(w, "not found", http.StatusNotFound) + return + } var body struct { - Role string `json:"role"` - Password string `json:"password"` - DepartmentIDs []int `json:"department_ids"` + Roles []string `json:"roles"` + 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) @@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if body.DepartmentIDs == nil { body.DepartmentIDs = []int{} } - if body.Role != "" { - if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil { + if body.Roles != nil { + if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, _ := app.getUserByID(id) + user, _ := app.getUser(id) writeJSON(w, user) } @@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.UserID == id { + if claims.ParticipantID == id { writeError(w, "cannot delete yourself", http.StatusBadRequest) return } - if err := app.deleteUser(id); err != nil { + if err := app.removeUser(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } diff --git a/handle_volunteers.go b/handle_volunteers.go index 5d086ad..ec3a317 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -21,7 +21,7 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { } claims := claimsFromContext(r) - if claims.Role == "colead" && deptID == nil && len(claims.DeptIDs) > 0 { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { deptID = &claims.DeptIDs[0] } @@ -55,7 +55,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,7 +127,7 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if claims.Role == "colead" { + if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -171,7 +171,7 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) - v, err := app.markVolunteerReady(id, claims.UserID) + v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go index ab51b9b..19ff1b0 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -65,9 +65,9 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { app := testApp(t) mux := testMux(app) - // Ticketing role should NOT be able to confirm volunteers. - ticketing := testUserWithRole(t, app, "ticket_lead", "ticketing", nil) - tok := testToken(t, app, ticketing) + // Gatekeeper role should NOT be able to confirm volunteers. + gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{}) + tok := testToken(t, app, gatekeeper) p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true}) v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID}) @@ -75,7 +75,7 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { w := httptest.NewRecorder() mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) if w.Code != http.StatusForbidden { - t.Errorf("expected 403 for ticketing role, got %d", w.Code) + t.Errorf("expected 403 for gatekeeper role, got %d", w.Code) } } diff --git a/main.go b/main.go index be9fc53..f775bb6 100644 --- a/main.go +++ b/main.go @@ -97,62 +97,62 @@ func (app *App) registerRoutes(mux *http.ServeMux) { 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", "ticketing")) + mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin")) - mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "ticketing", "gatekeeper")) - mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin", "ticketing")) - mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin", "ticketing")) - mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "ticketing", "gatekeeper")) - mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin", "ticketing")) - mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin", "ticketing")) - mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) + mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin")) + mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin")) + mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper")) + mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin")) + mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin")) + mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin")) - mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper")) - mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing")) - mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper")) - mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) - mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing")) - mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin", "ticketing")) - mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin", "ticketing")) + mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin")) + mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper")) + mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin")) + mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin")) + mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin")) + mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin")) mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments)) - mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "ticketing", "staffing")) - mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "ticketing", "staffing")) - mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin", "ticketing")) + mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing")) + mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing")) + mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin")) - mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead")) mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) - mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "ticketing", "staffing", "colead")) + mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead")) + mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead")) + mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead")) + mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead")) - mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin", "ticketing")) - mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin", "ticketing")) - mux.HandleFunc("PUT /api/users/{id}", auth(app.handleUpdateUser, "admin", "ticketing")) - mux.HandleFunc("DELETE /api/users/{id}", auth(app.handleDeleteUser, "admin", "ticketing")) + 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", "ticketing")) - mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin", "ticketing")) - mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin", "ticketing")) + 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/settings/reset-tickets", auth(app.handleResetTickets, "admin")) + mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin")) + mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin")) + mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin")) + mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin")) - mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing")) + mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin")) mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull)) mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream)) @@ -161,7 +161,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { writeJSON(w, map[string]string{"build": buildID}) }) - mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "ticketing", "staffing")) + mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) // Public endpoints — no JWT required. mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) @@ -196,9 +196,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) { } func (app *App) bootstrapAdmin() error { - adminUser := os.Getenv("TURNPIKE_ADMIN_USER") + adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL") adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD") - if adminUser == "" || adminPass == "" { + if adminEmail == "" || adminPass == "" { return nil } n, err := app.countUsers() @@ -209,11 +209,11 @@ func (app *App) bootstrapAdmin() error { if err != nil { return err } - _, err = app.createUser(adminUser, hash, "admin", []int{}) + _, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{}) if err != nil { return err } - log.Printf("Created admin user: %s", adminUser) + log.Printf("Created admin user: %s", adminEmail) return nil } diff --git a/testutil_test.go b/testutil_test.go index 8f58833..14351e5 100644 --- a/testutil_test.go +++ b/testutil_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -16,7 +17,6 @@ func testApp(t *testing.T) *App { 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, @@ -29,17 +29,18 @@ func testApp(t *testing.T) *App { func testAdminUser(t *testing.T, app *App) *User { t.Helper() hash, _ := hashPassword("admin123") - u, err := app.createUser("admin", hash, "admin", []int{}) + u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{}) if err != nil { t.Fatal(err) } return u } -func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User { +func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User { t.Helper() - hash, _ := hashPassword(username + "123") - u, err := app.createUser(username, hash, role, deptIDs) + email := strings.ToLower(name) + "@athens.example" + hash, _ := hashPassword(name + "123") + u, err := app.createUser(email, name, hash, roles, deptIDs) if err != nil { t.Fatal(err) } From da5f3524fa42a97a0f8c2ea44929f1d6cdf449e5 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 14:24:51 -0500 Subject: [PATCH 44/54] Fixed db resync. --- frontend/src/db.js | 11 ++++++++++- frontend/src/sync.js | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/db.js b/frontend/src/db.js index cbc0d38..bfddf9f 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -51,7 +51,16 @@ db.version(5).stores({ participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at', }) -db.version(6).stores({}).upgrade(tx => tx.table('session').clear()) +db.version(6).stores({}).upgrade(async tx => { + await tx.table('session').clear() + await tx.table('meta').clear() + await tx.table('participants').clear() + await tx.table('tickets').clear() + await tx.table('departments').clear() + await tx.table('volunteers').clear() + await tx.table('shifts').clear() + await tx.table('volunteer_shifts').clear() +}) export async function getLastSync() { const m = await db.meta.get('last_sync') diff --git a/frontend/src/sync.js b/frontend/src/sync.js index fa3b641..e2c4bff 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -4,10 +4,36 @@ import { api } from './api.js' let syncing = false let sseSource = null +async function checkBuildChanged() { + try { + const res = await fetch('/api/version') + const { build } = await res.json() + if (!build) return + const stored = await db.meta.get('build') + if (!stored || stored.value !== build) { + await db.transaction('rw', + [db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + await db.meta.clear() + await db.event.clear() + await db.participants.clear() + await db.tickets.clear() + await db.departments.clear() + await db.volunteers.clear() + await db.shifts.clear() + await db.volunteer_shifts.clear() + } + ) + } + await db.meta.put({ key: 'build', value: build }) + } catch {} +} + export async function syncPull() { if (syncing) return syncing = true try { + await checkBuildChanged() const since = await getLastSync() const data = await api.sync.pull(since) @@ -51,7 +77,7 @@ export async function syncPull() { } ) - await setLastSync(data.server_time) + if (data.server_time) await setLastSync(data.server_time) return true } catch (err) { console.warn('Sync pull failed:', err.message) @@ -97,7 +123,7 @@ export function startSSE(onEvent) { syncPull() }, 5000) } - }) + }).catch(() => {}) } connect() @@ -108,18 +134,23 @@ export function stopSSE() { sseSource = null } -// Poll for sync when online, with exponential backoff on failure let syncInterval = null +let onlineHandler = null export function startSyncLoop(intervalMs = 30000) { if (syncInterval) return syncInterval = setInterval(() => { if (navigator.onLine) syncPull() }, intervalMs) - window.addEventListener('online', () => syncPull()) + onlineHandler = () => syncPull() + window.addEventListener('online', onlineHandler) } export function stopSyncLoop() { clearInterval(syncInterval) syncInterval = null + if (onlineHandler) { + window.removeEventListener('online', onlineHandler) + onlineHandler = null + } } From 7dbcd052620f92d953a21e1a63b072533a3e54b4 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:14:36 -0500 Subject: [PATCH 45/54] Rescoped colead role and revised session handling. --- auth.go | 14 +++ db.go | 31 ++++-- frontend/src/App.svelte | 3 +- frontend/src/db.js | 16 ++- frontend/src/pages/Dashboard.svelte | 12 +-- frontend/src/pages/ScheduleBoard.svelte | 12 ++- frontend/src/sync.js | 2 +- handle_settings.go | 2 +- handle_shifts.go | 51 ++++++++-- handle_shifts_test.go | 80 +++++++++++++++ handle_volunteers.go | 79 +++++++++++---- handle_volunteers_test.go | 124 ++++++++++++++++++++++++ 12 files changed, 376 insertions(+), 50 deletions(-) diff --git a/auth.go b/auth.go index 0cc812a..b675e6f 100644 --- a/auth.go +++ b/auth.go @@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool { return false } +func isCoLeadOnly(claims *Claims) bool { + return hasAnyRole(claims.Roles, []string{"colead"}) && + !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) +} + +func inSlice(v int, s []int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + func claimsFromContext(r *http.Request) *Claims { c, _ := r.Context().Value(claimsKey).(*Claims) return c diff --git a/db.go b/db.go index 99b335c..7857020 100644 --- a/db.go +++ b/db.go @@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error { ); err != nil { return err } + app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID) + app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID) _, err := app.db.Exec( `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID, ) @@ -1206,7 +1208,7 @@ const volunteerSelect = `v.id, v.participant_id, v.created_at, v.updated_at, v.deleted_at` const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id` -func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { +func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { @@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu s := "%" + search + "%" args = append(args, s, s) } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND v.department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) @@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) { // --- Shifts --- -func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if deptID != nil { + if len(deptIDs) == 1 { q += ` AND department_id = ?` - args = append(args, *deptID) + args = append(args, deptIDs[0]) + } else if len(deptIDs) > 1 { + q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)` + for _, id := range deptIDs { + args = append(args, id) + } } if day != "" { q += ` AND day = ?` @@ -1669,3 +1681,10 @@ func boolInt(b bool) int { } return 0 } + +func placeholders(n int) string { + if n <= 0 { + return "" + } + return strings.Repeat("?,", n-1) + "?" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 52c6741..a1fa253 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -84,7 +84,6 @@ const path = $derived(route || '/') const roles = $derived(session?.user?.roles ?? []) - function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } {#if updateAvailable} @@ -125,7 +124,7 @@ {#if roles.length === 1 && roles[0] === 'colead'} {:else} - + {/if} {:else if path.startsWith('/participants')} diff --git a/frontend/src/db.js b/frontend/src/db.js index bfddf9f..bd3b490 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -80,6 +80,18 @@ export async function saveSession(token, user) { } export async function clearSession() { - await db.session.clear() - await db.meta.clear() + await db.transaction('rw', + [db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts], + async () => { + await db.session.clear() + await db.meta.clear() + await db.event.clear() + await db.participants.clear() + await db.tickets.clear() + await db.departments.clear() + await db.volunteers.clear() + await db.shifts.clear() + await db.volunteer_shifts.clear() + } + ) } diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 0f6decc..9756b31 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -2,7 +2,7 @@ import { liveQuery } from 'dexie' import { db } from '../db.js' - let { session } = $props() + let { session, navigate } = $props() const roles = $derived(session?.user?.roles ?? []) function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } @@ -147,14 +147,14 @@ {#if isAdmin} {:else if isStaffing || isColead} {/if} diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 922a0b0..6bea05e 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -135,11 +135,13 @@ try { const res = await api.shifts.reorder(positions) - if (res && !res.ok) throw new Error() - for (const p of positions) { - const s = await db.shifts.get(p.id) - if (s) await db.shifts.put({ ...s, position: p.position }) - } + if (res && !res.ok) throw new Error('Reorder failed') + await db.transaction('rw', db.shifts, async () => { + for (const p of positions) { + const s = await db.shifts.get(p.id) + if (s) await db.shifts.put({ ...s, position: p.position }) + } + }) } catch (err) { error = err.message } diff --git a/frontend/src/sync.js b/frontend/src/sync.js index e2c4bff..ef22313 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -22,10 +22,10 @@ async function checkBuildChanged() { await db.volunteers.clear() await db.shifts.clear() await db.volunteer_shifts.clear() + await db.meta.put({ key: 'build', value: build }) } ) } - await db.meta.put({ key: 'build', value: build }) } catch {} } diff --git a/handle_settings.go b/handle_settings.go index d4ed01c..2c0c991 100644 --- a/handle_settings.go +++ b/handle_settings.go @@ -58,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { var val string switch vv := v.(type) { case string: - if k == "smtp_password" && vv == "" { + if k == "smtp_password" && (vv == "" || vv == "***") { continue } val = vv diff --git a/handle_shifts.go b/handle_shifts.go index 67312c5..9299916 100644 --- a/handle_shifts.go +++ b/handle_shifts.go @@ -8,20 +8,19 @@ import ( func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since")) + shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since")) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !inSlice(s.DepartmentID, claims.DeptIDs) { + if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } @@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getShift(id) if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) @@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(id) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteShift(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques writeError(w, "volunteer_id required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if !body.Force { conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID) @@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ writeError(w, "invalid volunteer id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + s, _ := app.getShift(shiftID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) { writeError(w, "array of {id, position} required", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + for _, p := range raw { + s, _ := app.getShift(p.ID) + if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + } positions := make([]struct{ ID, Position int }, len(raw)) for i, p := range raw { positions[i] = struct{ ID, Position int }{p.ID, p.Position} diff --git a/handle_shifts_test.go b/handle_shifts_test.go index 940164e..19c49bf 100644 --- a/handle_shifts_test.go +++ b/handle_shifts_test.go @@ -104,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) { } } +func TestCoLeadDeleteShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadDeleteShiftOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{ + "volunteer_id": v.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReorderShiftsOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{ + {"id": s1.ID, "position": 2}, + {"id": s2.ID, "position": 1}, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept reorder, got %d", w.Code) + } +} + func TestShiftReorder(t *testing.T) { app := testApp(t) admin := testAdminUser(t, app) diff --git a/handle_volunteers.go b/handle_volunteers.go index ec3a317..cd891d2 100644 --- a/handle_volunteers.go +++ b/handle_volunteers.go @@ -12,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) { search := q.Get("search") since := q.Get("since") - var deptID *int + var deptIDs []int if d := q.Get("dept"); d != "" { - id, err := strconv.Atoi(d) - if err == nil { - deptID = &id + if id, err := strconv.Atoi(d); err == nil { + deptIDs = []int{id} } } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && deptID == nil && len(claims.DeptIDs) > 0 { - deptID = &claims.DeptIDs[0] + if isCoLeadOnly(claims) && len(deptIDs) == 0 { + deptIDs = claims.DeptIDs } - volunteers, err := app.listVolunteers(search, deptID, since) + volunteers, err := app.listVolunteers(search, deptIDs, since) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return @@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) { return } claims := claimsFromContext(r) - if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) { + if isCoLeadOnly(claims) { existing, _ := app.getVolunteer(id) if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) { writeError(w, "forbidden: outside your department", http.StatusForbidden) return } + if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden) + return + } } v := Volunteer{ ID: id, @@ -157,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.deleteVolunteer(id); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) return } claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.markVolunteerReady(id, claims.ParticipantID) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -186,6 +204,14 @@ func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(id) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } v, err := app.confirmVolunteer(id) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -207,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "shift_id required", http.StatusBadRequest) return } - if err := app.assignShift(volunteerID, body.ShiftID); err != nil { + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } + shift, err := app.getShift(body.ShiftID) + if err != nil || shift == nil { + writeError(w, "shift not found", http.StatusNotFound) + return + } + if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil { + if err == errShiftFull { + writeError(w, "shift is at capacity", http.StatusConflict) + return + } writeError(w, err.Error(), http.StatusInternalServerError) return } @@ -225,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { writeError(w, "invalid shift id", http.StatusBadRequest) return } + claims := claimsFromContext(r) + if isCoLeadOnly(claims) { + v, _ := app.getVolunteer(volunteerID) + if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) { + writeError(w, "forbidden: outside your department", http.StatusForbidden) + return + } + } if err := app.unassignShift(volunteerID, shiftID); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -232,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) { 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/handle_volunteers_test.go b/handle_volunteers_test.go index 19ff1b0..dc61f28 100644 --- a/handle_volunteers_test.go +++ b/handle_volunteers_test.go @@ -79,6 +79,130 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) { } } +func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadReadyVolunteerOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadAssignShiftOtherDept(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptBID := deptB.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID}) + s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{ + "shift_id": s.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for other dept, got %d", w.Code) + } +} + +func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) { + app := testApp(t) + mux := testMux(app) + + deptA, _ := app.createDepartment(Department{Name: "Gate"}) + deptB, _ := app.createDepartment(Department{Name: "Build"}) + colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID}) + tok := testToken(t, app, colead) + + deptAID := deptA.ID + p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"}) + v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID}) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{ + "department_id": deptB.ID, + }, tok)) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String()) + } +} + func TestUpdateVolunteerDepartment(t *testing.T) { app := testApp(t) mux := testMux(app) From ad8c3a64b64d8bbd98873513503e67a5ecd2ec93 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:27:49 -0500 Subject: [PATCH 46/54] Removed dead attendees DB code. --- db.go | 325 +---------------------------------------------------- db_test.go | 94 +--------------- 2 files changed, 4 insertions(+), 415 deletions(-) diff --git a/db.go b/db.go index 7857020..0315da8 100644 --- a/db.go +++ b/db.go @@ -49,28 +49,6 @@ func migrate(db *sql.DB) error { 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 participants(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, participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE, @@ -122,6 +100,8 @@ func migrate(db *sql.DB) error { note TEXT NOT NULL DEFAULT '', email_confirmed INTEGER NOT NULL DEFAULT 0, confirmation_token TEXT, + password_hash TEXT, + login_enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT @@ -161,95 +141,11 @@ func migrate(db *sql.DB) error { PRIMARY KEY (participant_id, department_id) ); `) - if err != nil { - return err - } - if err := migrateAuth(db); err != nil { - return err - } - return nil -} - -func migrateAuth(db *sql.DB) error { - // Add auth columns to participants (idempotent — ignore "duplicate column" errors). - db.Exec(`ALTER TABLE participants ADD COLUMN password_hash TEXT`) - db.Exec(`ALTER TABLE participants ADD COLUMN login_enabled INTEGER NOT NULL DEFAULT 0`) - - // Migrate users → participants if the old users table exists. - var hasUsers int - if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`).Scan(&hasUsers); err != nil || hasUsers == 0 { - return nil - } - - // Collect all users first (single connection — can't query and exec concurrently). - type oldUser struct { - id int - name string - hash string - role string - } - rows, err := db.Query(`SELECT id, username, password_hash, role FROM users`) - if err != nil { - return nil - } - var users []oldUser - for rows.Next() { - var u oldUser - if err := rows.Scan(&u.id, &u.name, &u.hash, &u.role); err != nil { - continue - } - if u.role == "ticketing" { - u.role = "admin" - } - users = append(users, u) - } - rows.Close() - - // Collect department assignments. - type deptAssign struct { - userID int - deptID int - } - deptRows, err := db.Query(`SELECT user_id, department_id FROM user_departments`) - var deptAssigns []deptAssign - if err == nil { - for deptRows.Next() { - var da deptAssign - deptRows.Scan(&da.userID, &da.deptID) - deptAssigns = append(deptAssigns, da) - } - deptRows.Close() - } - - // Now insert with the connection free. - for _, u := range users { - res, err := db.Exec( - `INSERT INTO participants (preferred_name, password_hash, login_enabled, updated_at) VALUES (?, ?, 1, ?)`, - u.name, u.hash, now(), - ) - if err != nil { - continue - } - pid, _ := res.LastInsertId() - db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) VALUES (?, ?)`, pid, u.role) - for _, da := range deptAssigns { - if da.userID == u.id { - db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, pid, da.deptID) - } - } - } - - db.Exec(`DROP TABLE IF EXISTS user_departments`) - db.Exec(`DROP TABLE IF EXISTS users`) - return nil + return err } // --- 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` @@ -273,25 +169,6 @@ type User struct { 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"` @@ -688,174 +565,6 @@ func (app *App) generateCodesForAll() (int, error) { return count, nil } -// incrementPartySize is kept for backward compatibility with existing tests. -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 -} - // --- Participants --- const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` @@ -1021,15 +730,6 @@ func (app *App) getTicket(id int) (*Ticket, error) { return &rows[0], nil } -func (app *App) getTicketByCode(code string) (*Ticket, error) { - rows, err := queryTickets(app.db, - `SELECT `+ticketCols+` FROM tickets WHERE code = ? AND deleted_at IS NULL`, code) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - func (app *App) createTicket(t Ticket) (*Ticket, error) { res, err := app.db.Exec( `INSERT INTO tickets (participant_id, name, ticket_type, source, external_id, order_id, code, updated_at) @@ -1066,16 +766,6 @@ func (app *App) deleteTicket(id int) error { return err } -func (app *App) ticketsSince(since string) ([]Ticket, error) { - return queryTickets(app.db, - `SELECT `+ticketCols+` FROM tickets WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - -func (app *App) participantsSince(since string) ([]Participant, error) { - return queryParticipants(app.db, - `SELECT `+participantCols+` FROM participants WHERE updated_at > ? ORDER BY updated_at ASC`, since) -} - func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { rows, err := db.Query(q, args...) if err != nil { @@ -1244,15 +934,6 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { return &rows[0], nil } -func (app *App) getVolunteerByParticipantID(participantID int) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.participant_id = ? AND v.deleted_at IS NULL LIMIT 1`, participantID) - 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 (participant_id, department_id, is_lead, note, updated_at) diff --git a/db_test.go b/db_test.go index edb2b6d..5755d08 100644 --- a/db_test.go +++ b/db_test.go @@ -7,7 +7,7 @@ import ( func TestMigrate(t *testing.T) { app := testApp(t) // Verify tables exist by querying each one - tables := []string{"event", "participants", "participant_roles", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"} + tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"} for _, table := range tables { var count int err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) @@ -17,98 +17,6 @@ func TestMigrate(t *testing.T) { } } -func TestAttendeesCRUD(t *testing.T) { - app := testApp(t) - - a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"}) - if err != nil { - t.Fatal(err) - } - if a.ID == 0 || a.Name != "Titania" { - 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 != "titania@test.com" { - t.Errorf("get: email = %q", got.Email) - } - - got.Name = "Titania Fairweather" - if err := app.updateAttendee(*got); err != nil { - t.Fatal(err) - } - got2, _ := app.getAttendee(a.ID) - if got2.Name != "Titania Fairweather" { - 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: "Oberon", TicketID: "ORD-100"}) - - merged, err := app.incrementPartySize("Oberon", "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("Oberon", "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: "Puck"}) - // 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 { From 5527c1eb91df025343052c06faef1eaac2d29578 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 15:37:34 -0500 Subject: [PATCH 47/54] Clarified distinction between Preferred and Ticketed Name. --- frontend/src/pages/Participants.svelte | 22 +++++++++++++++------- frontend/src/pages/Users.svelte | 4 ++-- frontend/src/pages/Volunteers.svelte | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index fa850a3..65eda24 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -19,6 +19,7 @@ let showAdd = $state(false) let adding = $state(false) let newName = $state('') + let newTicketedName = $state('') let newEmail = $state('') let newPhone = $state('') let newPronouns = $state('') @@ -27,6 +28,7 @@ // Edit participant let editId = $state(null) let editName = $state('') + let editTicketedName = $state('') let editEmail = $state('') let editPhone = $state('') let editPronouns = $state('') @@ -151,12 +153,12 @@ adding = true; error = '' try { const p = await api.participants.create({ - preferred_name: newName, email: newEmail, phone: newPhone, - pronouns: newPronouns, note: newNote, + preferred_name: newName, ticket_name: newTicketedName, email: newEmail, + phone: newPhone, pronouns: newPronouns, note: newNote, }) await db.participants.put(p) showAdd = false - newName = newEmail = newPhone = newPronouns = newNote = '' + newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = '' } catch (err) { error = err.message } finally { @@ -167,6 +169,7 @@ function startEdit(p) { editId = p.id editName = p.preferred_name + editTicketedName = p.ticket_name || '' editEmail = p.email editPhone = p.phone editPronouns = p.pronouns @@ -178,8 +181,8 @@ saving = true; error = '' try { const p = await api.participants.update(editId, { - preferred_name: editName, email: editEmail, phone: editPhone, - pronouns: editPronouns, note: editNote, + preferred_name: editName, ticket_name: editTicketedName, email: editEmail, + phone: editPhone, pronouns: editPronouns, note: editNote, }) await db.participants.put(p) editId = null @@ -247,9 +250,13 @@
- +
+
+ + +
@@ -324,7 +331,7 @@
UsernameRoleNameRoles Departments
{u.username} {#if u.id === me}you{/if}{u.preferred_name || u.email} {#if u.id === me}you{/if} - editRoles = toggleItem(r, editRoles)} /> + {roleLabel(r)} + {/each} - + {#if ($allDepts ?? []).length > 0} @@ -229,7 +244,7 @@ {/each} @@ -251,18 +266,19 @@ {:else}
- {u.username} + {u.preferred_name || u.email} {#if u.id === me} you {/if} +
{u.email}
{roleLabel(u.role)}{#each u.roles ?? [] as r}{roleLabel(r)}{/each} {deptNamesFor(u.department_ids || [])}
{#if u.id !== me} - + {/if}
- + @@ -344,6 +351,7 @@
+ diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index 328cc66..cb7a3f8 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -155,7 +155,7 @@
- +
@@ -214,7 +214,7 @@
NamePreferred Name Email Tickets Status
- + diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index 79681f8..d61cc3d 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -256,7 +256,7 @@
NamePreferred Name Roles Departments
- + @@ -296,6 +296,7 @@ {:else} + {@const participant = participantFor(v.participant_id)} - +
NamePreferred Name Department Status
{v.name} @@ -307,6 +308,9 @@ {:else if !participantHasTickets(v.participant_id)} No ticket {/if} + {#if participant?.ticket_name && participant.ticket_name !== v.name} +
Ticket: {participant.ticket_name}
+ {/if} {#if v.email}
{v.email}
{/if} From 54da04763f35a6752588ea71e450268fe0c4a41b Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Tue, 10 Mar 2026 17:45:38 -0500 Subject: [PATCH 48/54] Added optional Discourse SSO. --- db.go | 26 ++++ frontend/src/App.svelte | 32 ++++- frontend/src/api.js | 4 + frontend/src/pages/Login.svelte | 49 +++++++- frontend/src/pages/Settings.svelte | 27 +++- handle_settings.go | 14 ++- handle_sso.go | 190 +++++++++++++++++++++++++++++ main.go | 3 + 8 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 handle_sso.go diff --git a/db.go b/db.go index 0315da8..0ec6716 100644 --- a/db.go +++ b/db.go @@ -140,6 +140,11 @@ func migrate(db *sql.DB) error { department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, PRIMARY KEY (participant_id, department_id) ); + + CREATE TABLE IF NOT EXISTS sso_nonces ( + nonce TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) return err } @@ -1350,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) { ORDER BY s.day, s.position, s.start_time`, deptID) } +// --- SSO Nonces --- + +func (app *App) createSSONonce(nonce string) error { + _, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce) + return err +} + +func (app *App) consumeSSONonce(nonce string) (bool, error) { + res, err := app.db.Exec( + `DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +func (app *App) cleanExpiredNonces() { + app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`) +} + // --- Helpers --- func now() string { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a1fa253..f680143 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 2f6ee8e..bc87738 100644 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -26,6 +26,8 @@ let eventEndDate = $state('') let eventTimezone = $state('') const timezones = Intl.supportedValuesOf('timeZone') + let discourseSSOUrl = $state('') + let discourseSSOSecret = $state('') let shiftSignupsOpen = $state(false) let togglingSignups = $state(false) @@ -49,6 +51,8 @@ baseURL = s.base_url ?? '' noteLabel = s.volunteer_note_label ?? 'Additional note' noteRequired = s.volunteer_note_required ?? false + discourseSSOUrl = s.discourse_sso_url ?? '' + discourseSSOSecret = '' shiftSignupsOpen = s.shift_signups_open ?? false } catch (err) { error = err.message @@ -89,14 +93,17 @@ smtp_host: smtpHost, smtp_port: smtpPort, smtp_user: smtpUser, - smtp_password: smtpPassword, // empty = keep existing + smtp_password: smtpPassword, smtp_from: smtpFrom, smtp_from_name: smtpFromName, base_url: baseURL, volunteer_note_label: noteLabel, volunteer_note_required: noteRequired, + discourse_sso_url: discourseSSOUrl, + discourse_sso_secret: discourseSSOSecret, }) smtpPassword = '' + discourseSSOSecret = '' success = 'Settings saved.' } catch (err) { error = err.message @@ -240,6 +247,24 @@ + +

Discourse SSO

+

+ Enable DiscourseConnect SSO so users can log in with their Discourse account. + Set the same secret in your Discourse admin under Connect > discourse connect secret. +

+
+
+ + +
+
+ + +
+
+
-
{#each availableRoles as r} -
- From 374316944ecb2cebd5ba61dfde7435e3721caa07 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:04:30 -0500 Subject: [PATCH 51/54] Refactored inline styles. --- frontend/src/app.css | 6 +++ frontend/src/pages/Dashboard.svelte | 2 +- frontend/src/pages/Participants.svelte | 2 +- frontend/src/pages/ScheduleBoard.svelte | 4 +- frontend/src/pages/Settings.svelte | 53 ++++++++++++------------- frontend/src/pages/Users.svelte | 4 +- frontend/src/pages/Volunteers.svelte | 8 ++-- 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 70ec51c..0395a12 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); } /* Cards */ .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } +.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; } +.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; } +.card-hint { font-size: 0.78rem; color: var(--c-muted); } /* Stats */ .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } @@ -106,6 +109,8 @@ input, select, textarea { input[type="checkbox"] { width: auto; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.form-grid .full { grid-column: 1 / -1; } .checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } .checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } @@ -131,6 +136,7 @@ tr:hover td { background: rgba(255,255,255,0.02); } padding: 0.18rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; + margin-left: 0.3rem; } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 9756b31..73800d6 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -160,7 +160,7 @@

Welcome, {session?.user?.preferred_name} - · {#each roles as r}{r}{/each} + · {#each roles as r}{r}{/each}

diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index 65eda24..0be1485 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -248,7 +248,7 @@ {#if showAdd && canManage}
-
+
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte index 6bea05e..a09bf0f 100644 --- a/frontend/src/pages/ScheduleBoard.svelte +++ b/frontend/src/pages/ScheduleBoard.svelte @@ -275,7 +275,7 @@ {#if showAdd && canManage}
-
+
-
+
@@ -192,7 +192,7 @@
-
+
@@ -211,11 +211,11 @@
-
-

SMTP Email

+
+

SMTP Email

-
-
+
+
@@ -243,22 +243,21 @@
- +
- -

Discourse SSO

-

+

Discourse SSO

+

Enable DiscourseConnect SSO so users can log in with their Discourse account. Set the same secret in your Discourse admin under Connect > discourse connect secret.

-
-
+
+
-
+
@@ -274,8 +273,8 @@ -
-

Test Email

+
+

Test Email

@@ -288,8 +287,8 @@
-
-

Volunteer Signup

+
+

Volunteer Signup

@@ -298,14 +297,14 @@ Note field is required -

+

Signup form: /volunteer-signup

-
-

Shift Signups

+
+

Shift Signups

Status: {shiftSignupsOpen ? 'Open' : 'Closed'} @@ -320,7 +319,7 @@
{#if !shiftSignupsOpen} -

+

Opening signups will email all confirmed volunteers their shift signup links.

{/if} @@ -328,8 +327,8 @@
-

Data Management

-

+

Data Management

+

Permanently delete all records of a given type. This cannot be undone.

diff --git a/frontend/src/pages/Users.svelte b/frontend/src/pages/Users.svelte index c1c3a58..e75364c 100644 --- a/frontend/src/pages/Users.svelte +++ b/frontend/src/pages/Users.svelte @@ -268,11 +268,11 @@
{u.preferred_name || u.email} {#if u.id === me} - you + you {/if}
{u.email}
{#each u.roles ?? [] as r}{roleLabel(r)}{/each}{#each u.roles ?? [] as r}{roleLabel(r)}{/each} {deptNamesFor(u.department_ids || [])}
diff --git a/frontend/src/pages/Volunteers.svelte b/frontend/src/pages/Volunteers.svelte index fb92897..4bced77 100644 --- a/frontend/src/pages/Volunteers.svelte +++ b/frontend/src/pages/Volunteers.svelte @@ -181,7 +181,7 @@ {#if showAdd && canManage}
-
+
@@ -301,12 +301,12 @@
{v.name} {#if v.is_lead} - Co-Lead + Co-Lead {/if} {#if !v.participant_id} - No ticket + No ticket {:else if !participantHasTickets(v.participant_id)} - No ticket + No ticket {/if} {#if participant?.ticket_name && participant.ticket_name !== v.name}
Ticket: {participant.ticket_name}
From 6d4c49a223ece4c0dae2e74bc048b043e2b08571 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:22:54 -0500 Subject: [PATCH 52/54] Added autozoom fix. --- frontend/src/app.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 0395a12..546dc22 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -243,6 +243,7 @@ tr:hover td { background: rgba(255,255,255,0.02); } td { display: inline; padding: 0; border: none; } td:empty { display: none; } - /* Forms */ + /* Forms — 16px prevents iOS auto-zoom on focus */ + input, select, textarea { font-size: 16px; } .form-grid { grid-template-columns: 1fr !important; } } From d73a74965d2a04f432215dbd4065a81acb654944 Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 10:31:50 -0500 Subject: [PATCH 53/54] Added datepicker fix. --- frontend/src/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app.css b/frontend/src/app.css index 546dc22..b61ed5e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -107,6 +107,7 @@ input, select, textarea { transition: border-color var(--transition); } input[type="checkbox"] { width: auto; } +input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } From d64e93674e2960de61dc8968c5b8f742e67d548b Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 11 Mar 2026 12:26:33 -0500 Subject: [PATCH 54/54] Refactored styles. --- frontend/src/App.svelte | 1 + frontend/src/api.js | 4 +- frontend/src/app.css | 5 +- frontend/src/pages/Departments.svelte | 5 +- frontend/src/pages/Login.svelte | 6 ++- frontend/src/pages/ScheduleBoard.svelte | 64 +++++++++++++------------ frontend/src/pages/Settings.svelte | 15 ++++-- frontend/src/pages/Users.svelte | 2 +- frontend/src/pages/Volunteers.svelte | 7 ++- 9 files changed, 64 insertions(+), 45 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f680143..ac0957e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -37,6 +37,7 @@ history.pushState(null, '', path) route = path mobileNavOpen = false + window.scrollTo(0, 0) } async function checkVersion() { diff --git a/frontend/src/api.js b/frontend/src/api.js index 31a517f..d15abc4 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,4 +1,4 @@ -import { db } from './db.js' +import { db, clearSession } from './db.js' async function getToken() { const session = await db.session.get(1) @@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) { const res = await fetch(path, { ...options, headers }) if (res.status === 401) { - await db.session.clear() + await clearSession() window.location.pathname = '/login' throw new Error('unauthorized') } diff --git a/frontend/src/app.css b/frontend/src/app.css index b61ed5e..3a685ae 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -111,6 +111,7 @@ input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-a input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input::placeholder { color: var(--c-muted); } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; } .form-grid .full { grid-column: 1 / -1; } .checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } .checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } @@ -137,8 +138,8 @@ tr:hover td { background: rgba(255,255,255,0.02); } padding: 0.18rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - margin-left: 0.3rem; } +* + .badge { margin-left: 0.3rem; } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } @@ -246,5 +247,5 @@ tr:hover td { background: rgba(255,255,255,0.02); } /* Forms — 16px prevents iOS auto-zoom on focus */ input, select, textarea { font-size: 16px; } - .form-grid { grid-template-columns: 1fr !important; } + .form-grid, .form-grid-3 { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/pages/Departments.svelte b/frontend/src/pages/Departments.svelte index 26164eb..863c4a1 100644 --- a/frontend/src/pages/Departments.svelte +++ b/frontend/src/pages/Departments.svelte @@ -101,7 +101,7 @@ {#if showAdd && canCreate}
-
+
@@ -112,7 +112,7 @@
- +
@@ -191,6 +191,7 @@