diff --git a/Makefile b/Makefile index 72a39be..a8de0d3 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,37 @@ -.PHONY: build frontend-build dev clean +.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) | 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 -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:" @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 + +patch: + git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))) + @echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))" + +minor: + git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0 + @echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0" + +major: + git tag $(shell echo $$(($(MAJOR)+1))).0.0 + @echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0" diff --git a/README.md b/README.md index b526fbe..71132c4 100644 --- a/README.md +++ b/README.md @@ -1,20 +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 -- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering -- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required -- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in -- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness -- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate +- **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** — 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 - **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 @@ -59,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 board. No user management or settings | -| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | -| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | +| `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 Gate Kiosk with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -90,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 kiosk, gate check-in, schedule board +- [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/auth.go b/auth.go index c2d11af..b675e6f 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,9 +97,25 @@ 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 { +func hasAnyRole(roles []string, allowed []string) bool { + for _, r := range roles { + for _, a := range allowed { + if r == a { + return true + } + } + } + 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 } } diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..602c6cf --- /dev/null +++ b/auth_test.go @@ -0,0 +1,126 @@ +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{ + "email": admin.Email, + "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["email"] != "oberon@athens.example" { + 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{ + "email": "oberon@athens.example", + "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{ + "email": "nobody@test.com", + "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) + + gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []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["email"] != "oberon@athens.example" { + t.Errorf("email = %v", result["email"]) + } +} diff --git a/db.go b/db.go index 2d7b8d2..0ec6716 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','coordinator','gate','ticketing','volunteer_lead')), - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS user_departments ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, department_id) - ); - CREATE TABLE IF NOT EXISTS departments ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -63,44 +49,25 @@ 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 users(id), - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket - ON attendees(name, ticket_id) WHERE deleted_at IS NULL; - CREATE TABLE IF NOT EXISTS volunteers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, - name TEXT NOT NULL, - email TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, - is_lead INTEGER NOT NULL DEFAULT 0, - checked_in INTEGER NOT NULL DEFAULT 0, - checked_in_at TEXT, - note TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - deleted_at TEXT + 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,52 +86,71 @@ 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 '', + 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, + 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 + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_participants_email + ON participants(email) WHERE deleted_at IS NULL AND email != ''; + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + participant_id INTEGER REFERENCES participants(id) ON DELETE SET NULL, + name TEXT NOT NULL DEFAULT '', + ticket_type TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'manual', + external_id TEXT NOT NULL DEFAULT '', + order_id TEXT NOT NULL DEFAULT '', + code TEXT UNIQUE, + checked_in_at TEXT, + 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 + ); + + 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) + ); + + CREATE TABLE IF NOT EXISTS sso_nonces ( + nonce TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) - if err != nil { - return err - } - return migrateV2(db) -} - -// migrateV2 adds new columns to existing databases without data loss. -func migrateV2(db *sql.DB) error { - addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") - addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") - addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") - addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") - // Widen the uniqueness constraint from name-only to (name, ticket_id). - db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) - db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) - return nil -} - -func addColumnIfMissing(db *sql.DB, table, colDef string) { - colName := strings.Fields(colDef)[0] - rows, err := db.Query(`PRAGMA table_info("` + table + `")`) - if err != nil { - return - } - defer rows.Close() - for rows.Next() { - var cid, notNull, pk int - var name, typ string - var dflt sql.NullString - rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) - if name == colName { - return - } - } - db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) + 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` @@ -180,30 +166,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"` -} - -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"` + 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 Department struct { @@ -216,19 +184,56 @@ type Department struct { } type Volunteer struct { - ID int `json:"id"` - AttendeeID *int `json:"attendee_id,omitempty"` - Name string `json:"name"` - Email string `json:"email"` - Phone string `json:"phone"` - DepartmentID *int `json:"department_id,omitempty"` - IsLead bool `json:"is_lead"` - CheckedIn bool `json:"checked_in"` - CheckedInAt *string `json:"checked_in_at,omitempty"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - DeletedAt *string `json:"deleted_at,omitempty"` + ID int `json:"id"` + 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 { + ID int `json:"id"` + ParticipantID *int `json:"participant_id,omitempty"` + Name string `json:"name"` + TicketType string `json:"ticket_type"` + Source string `json:"source"` + ExternalID string `json:"external_id"` + OrderID string `json:"order_id"` + Code *string `json:"code,omitempty"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Shift struct { @@ -245,10 +250,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 --- @@ -277,11 +283,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 @@ -299,14 +339,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 } @@ -314,98 +353,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 } @@ -413,21 +511,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 tickets WHERE code = ?`, t).Scan(&count); err != nil { + return "", fmt.Errorf("check token uniqueness: %w", err) + } if count == 0 { return t, nil } @@ -435,30 +540,23 @@ func (app *App) generateUniqueToken() (string, error) { return "", fmt.Errorf("failed to generate unique token") } -func (app *App) getAttendeeByToken(token string) (*Attendee, error) { - rows, err := queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -// generateTokensForAll creates tokens for every attendee that doesn't have one yet. -func (app *App) generateTokensForAll() (int, error) { +// generateCodesForAll generates codes for every ticket that doesn't have one yet. +func (app *App) generateCodesForAll() (int, error) { rows, err := app.db.Query( - `SELECT id FROM attendees WHERE volunteer_token IS NULL AND deleted_at IS NULL`, + `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`, ) 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 ticket id: %w", err) + } ids = append(ids, id) } - rows.Close() count := 0 for _, id := range ids { @@ -466,161 +564,256 @@ func (app *App) generateTokensForAll() (int, error) { if err != nil { continue } - app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) + app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } -// incrementPartySize bumps party_size for an existing attendee matched by name+ticket_id. -// Used during import to handle duplicate ticket rows from the same order. -func (app *App) incrementPartySize(name, ticketID string) (bool, error) { - res, err := app.db.Exec( - `UPDATE attendees SET party_size = party_size + 1, updated_at = ? - WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, - now(), name, ticketID, - ) - if err != nil { - return false, err - } - n, _ := res.RowsAffected() - return n > 0, nil -} +// --- Participants --- -// --- Attendees --- +const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` -func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { - q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` +func (app *App) listParticipants(search, since string) ([]Participant, error) { + var q string 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 since != "" { + q = `SELECT ` + participantCols + ` FROM participants WHERE updated_at > ? ORDER BY preferred_name, email` + args = append(args, since) + } else { + q = `SELECT ` + participantCols + ` FROM participants WHERE deleted_at IS NULL` + if search != "" { + q += ` AND (preferred_name LIKE ? OR email LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s) + } + q += ` ORDER BY preferred_name, email` } - 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...) + return queryParticipants(app.db, q, args...) } -func (app *App) getAttendee(id int) (*Attendee, error) { - rows, err := queryAttendees(app.db, - `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) +func (app *App) getParticipant(id int) (*Participant, error) { + rows, err := queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE id = ?`, id) if err != nil || len(rows) == 0 { return nil, err } return &rows[0], nil } -func (app *App) createAttendee(a Attendee) (*Attendee, error) { +func (app *App) getParticipantByEmail(email string) (*Participant, error) { + rows, err := queryParticipants(app.db, + `SELECT `+participantCols+` FROM participants WHERE LOWER(email) = LOWER(?) AND deleted_at IS NULL LIMIT 1`, email) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createParticipant(p Participant) (*Participant, 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(), + `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 } id, _ := res.LastInsertId() - return app.getAttendee(int(id)) + return app.getParticipant(int(id)) } -func (app *App) updateAttendee(a Attendee) error { +func (app *App) updateParticipant(p Participant) 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, + `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.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID, ) return err } -func (app *App) deleteAttendee(id int) error { +func (app *App) deleteParticipant(id int) error { _, err := app.db.Exec( - `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, + `UPDATE participants 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 +// mergeParticipants reassigns all tickets and volunteers from other → canonical, then soft-deletes other. +func (app *App) mergeParticipants(canonicalID, otherID int) error { + ts := now() + if _, err := app.db.Exec( + `UPDATE tickets SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, + canonicalID, ts, otherID, + ); err != nil { + return err } - a, err := app.getAttendee(id) - if err != nil || a == nil { - return nil, err + if _, err := app.db.Exec( + `UPDATE volunteers SET participant_id=?, updated_at=? WHERE participant_id=? AND deleted_at IS NULL`, + canonicalID, ts, otherID, + ); err != nil { + return 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, + 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, ) - if err != nil { - return nil, err - } - return app.getAttendee(id) + return err } -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) { +func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() - var result []Attendee + var result []Participant for rows.Next() { - var a Attendee - var checkedIn int - var token sql.NullString + var p Participant + var emailConfirmed int + var confirmationToken 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, + &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 } - if token.Valid && token.String != "" { - a.VolunteerToken = &token.String + p.EmailConfirmed = emailConfirmed == 1 + if confirmationToken.Valid { + p.ConfirmationToken = &confirmationToken.String } - a.CheckedIn = checkedIn == 1 - if a.PartySize < 1 { - a.PartySize = 1 - } - result = append(result, a) + result = append(result, p) } return result, rows.Err() } -func (app *App) attendeeTicketTypes() ([]string, error) { +// upsertParticipant finds a participant by email or creates one. +// Returns the participant and whether it was newly created. +func (app *App) upsertParticipant(email, name string) (*Participant, bool, error) { + p, err := app.getParticipantByEmail(email) + if err != nil { + return nil, false, err + } + if p != nil { + return p, false, nil + } + created, err := app.createParticipant(Participant{ + Email: email, + PreferredName: name, + }) + return created, true, err +} + +// --- Tickets --- + +const ticketCols = `id, participant_id, name, ticket_type, source, external_id, order_id, code, checked_in_at, checked_in_by, created_at, updated_at, deleted_at` + +func (app *App) listTickets(participantID *int, since string) ([]Ticket, error) { + q := `SELECT ` + ticketCols + ` FROM tickets WHERE 1=1` + var args []any + if since != "" { + q += ` AND updated_at > ?` + args = append(args, since) + } else { + q += ` AND deleted_at IS NULL` + } + if participantID != nil { + q += ` AND participant_id = ?` + args = append(args, *participantID) + } + q += ` ORDER BY created_at` + return queryTickets(app.db, q, args...) +} + +func (app *App) getTicket(id int) (*Ticket, error) { + rows, err := queryTickets(app.db, + `SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) + 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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + t.ParticipantID, t.Name, t.TicketType, t.Source, t.ExternalID, t.OrderID, t.Code, now(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return app.getTicket(int(id)) +} + +func (app *App) checkInTicket(id, userID int) (*Ticket, error) { + t := now() + _, err := app.db.Exec(` + UPDATE tickets SET + checked_in_at = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_at END, + checked_in_by = CASE WHEN checked_in_at IS NULL THEN ? ELSE checked_in_by END, + updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + t, userID, t, id, + ) + if err != nil { + return nil, err + } + return app.getTicket(id) +} + +func (app *App) deleteTicket(id int) error { + _, err := app.db.Exec( + `UPDATE tickets SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + ) + return err +} + +func queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { + rows, err := db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Ticket + for rows.Next() { + var t Ticket + var participantID, checkedInBy sql.NullInt64 + var code sql.NullString + if err := rows.Scan( + &t.ID, &participantID, &t.Name, &t.TicketType, &t.Source, &t.ExternalID, &t.OrderID, + &code, &t.CheckedInAt, &checkedInBy, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt, + ); err != nil { + return nil, err + } + if participantID.Valid { + id := int(participantID.Int64) + t.ParticipantID = &id + } + if checkedInBy.Valid { + id := int(checkedInBy.Int64) + t.CheckedInBy = &id + } + if code.Valid && code.String != "" { + t.Code = &code.String + } + result = append(result, t) + } + return result, rows.Err() +} + +// ticketCounts returns total and checked-in ticket counts for participants page. +func (app *App) ticketCounts() (total, checkedIn int, err error) { + app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted_at IS NULL`).Scan(&total) + app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE checked_in_at IS NOT NULL AND deleted_at IS NULL`).Scan(&checkedIn) + return +} + +func (app *App) ticketTypes() ([]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`, + `SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, ) if err != nil { return nil, err @@ -635,12 +828,6 @@ func (app *App) attendeeTicketTypes() ([]string, error) { return types, rows.Err() } -func (app *App) attendeeCounts() (total, checkedIn int, err error) { - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE deleted_at IS NULL`).Scan(&total) - app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE checked_in=1 AND deleted_at IS NULL`).Scan(&checkedIn) - return -} - // --- Departments --- func (app *App) listDepartments(since string) ([]Department, error) { @@ -708,42 +895,44 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` +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, + p.email_confirmed, v.kiosk_code, v.note, + 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) { - q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` +func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { + q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` var args []any if since != "" { - q += ` AND updated_at > ?` + q += ` AND v.updated_at > ?` args = append(args, since) } else { - q += ` AND deleted_at IS NULL` + q += ` AND v.deleted_at IS NULL` } if search != "" { - q += ` AND (name LIKE ? OR email LIKE ?)` + q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` s := "%" + search + "%" args = append(args, s, s) } - if deptID != nil { - q += ` AND department_id = ?` - args = append(args, *deptID) + if len(deptIDs) == 1 { + q += ` AND v.department_id = ?` + 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 name` + q += ` ORDER BY p.preferred_name` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) - if err != nil || len(rows) == 0 { - return nil, err - } - return &rows[0], nil -} - -func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { - rows, err := queryVolunteers(app.db, - `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id) if err != nil || len(rows) == 0 { return nil, err } @@ -752,9 +941,9 @@ func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + `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 @@ -765,9 +954,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, + v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err } @@ -779,26 +968,30 @@ func (app *App) deleteVolunteer(id int) error { return err } -// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, -// also increments the attendee's checked_in_count. -func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { +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 { return nil, err } - v, err := app.getVolunteer(id) - if err != nil || v == nil { - return v, err + return app.getVolunteer(id) +} + +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 } - if v.AttendeeID != nil { - app.checkInAttendee(*v.AttendeeID, userID, 1) - } - return v, nil + return app.getVolunteer(id) } func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { @@ -810,33 +1003,119 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var attendeeID, deptID sql.NullInt64 - var isLead, checkedIn int + var deptID sql.NullInt64 + var isLead, ready, confirmed, emailConfirmed int + var confirmedAt, kioskCode sql.NullString if err := rows.Scan( - &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, - &isLead, &checkedIn, &v.CheckedInAt, &v.Note, + &v.ID, &v.ParticipantID, + &v.Name, &v.Email, &v.Phone, &v.Pronouns, + &deptID, &isLead, &ready, &v.ReadyAt, + &confirmed, &confirmedAt, + &emailConfirmed, &kioskCode, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } - if attendeeID.Valid { - id := int(attendeeID.Int64) - v.AttendeeID = &id - } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } + if confirmedAt.Valid { + v.ConfirmedAt = &confirmedAt.String + } + if kioskCode.Valid { + 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) } return result, rows.Err() } +func (app *App) getVolunteerByEmail(email string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `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 + } + return &rows[0], nil +} + +func (app *App) getVolunteerByConfirmationToken(token string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `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) confirmParticipantEmail(participantID int) error { + _, err := app.db.Exec( + `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 +} + +func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) assignKioskCode(id int, code string) error { + _, err := app.db.Exec( + `UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id) + return err +} + +func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) { + return queryVolunteers(app.db, ` + SELECT `+volunteerSelect+` `+volunteerFrom+` + WHERE p.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`) +} + +func (app *App) generateVolunteerKioskCode() (string, error) { + for range 10 { + t, err := generateToken() + if err != nil { + return "", err + } + var count int + if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil { + return "", fmt.Errorf("check kiosk code uniqueness: %w", err) + } + if count == 0 { + return t, nil + } + } + return "", fmt.Errorf("failed to generate unique kiosk code") +} + +func generateConfirmationToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("read random: %w", err) + } + return fmt.Sprintf("%x", b), nil +} + // --- 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 != "" { @@ -845,9 +1124,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 = ?` @@ -912,7 +1196,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 +1211,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 +1262,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 +1312,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 +1326,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 +1339,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,11 +1350,32 @@ 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) } +// --- 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 { @@ -1031,3 +1388,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/db_test.go b/db_test.go new file mode 100644 index 0000000..5755d08 --- /dev/null +++ b/db_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "testing" +) + +func TestMigrate(t *testing.T) { + app := testApp(t) + // Verify tables exist by querying each one + 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) + if err != nil { + t.Errorf("table %s: %v", table, err) + } + } +} + +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}) + 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) + } + 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 + 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"}) + 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 + 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"}) + // 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/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 80b25f0..c08ec50 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 Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings | -| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only | -| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages | +| **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 Kiosk | 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** — 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 attendees** — see next section. -4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity. +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,32 +52,67 @@ 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 +Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. -The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). +## Volunteer Signup -Re-importing the same CSV is safe — existing records are skipped, not duplicated. +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 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. + +Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration. + +### Configuring the signup form + +In **Settings**, the "Volunteer Signup" card controls: + +- **Note field label** — customize the label shown on the form (default: "Additional note") +- **Note field required** — when checked, volunteers must fill in the note to submit + +### Opening shift signups + +In **Settings**, the "Shift Signups" card has an open/close toggle: + +- **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. ## Managing Volunteers Under **Volunteers**, you can: -- Create volunteers manually (name, email, department) -- Link a volunteer to an existing attendee record (for dual check-in at the gate) -- Assign volunteers to departments -- Check in volunteers +- 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) -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. +### Volunteer statuses + +| 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 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. ## Shift Scheduling -Under **Shifts**, create shifts for each department: +Under **Schedule**, create shifts for each department: - **Day** — the date of the shift - **Start/end time** — HH:MM format @@ -86,27 +120,29 @@ Under **Shifts**, create shifts for each department: ### Assigning volunteers -From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. +From the Schedule page, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. ### Reordering -Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering. +Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card. ## 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 -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 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. ### 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 @@ -114,43 +150,45 @@ 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 +## Gate Kiosk -Users with the **gate** 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 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). - **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 Board +## Schedule -The Schedule Board is the primary UI for coordinators and volunteer leads. It shows: +The Schedule page is the primary UI for managing shifts and volunteer assignments. It shows: - Shifts grouped by department and day - Each shift card shows: name, time, capacity (used/total), assigned volunteers - Conflict badges when a volunteer has overlapping shifts on the same day -**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. +**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). Actions available: +- Create new shifts (+ Add shift button) +- Edit shift details inline +- Delete shifts - Assign volunteers to shifts from a dropdown - Remove volunteer assignments - Reorder shifts within a department -- Edit shift details inline ## SMTP Configuration -SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): +SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -171,13 +209,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 diff --git a/email.go b/email.go index 05c94f0..41a7a55 100644 --- a/email.go +++ b/email.go @@ -106,35 +106,73 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error { return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) } -// sendTokenEmail sends a volunteer token link to the attendee's email address. -func (app *App) sendTokenEmail(a Attendee) error { - if a.Email == "" { - return fmt.Errorf("attendee has no email address") - } - if a.VolunteerToken == nil || *a.VolunteerToken == "" { - return fmt.Errorf("attendee has no volunteer token") - } - - cfg := app.loadSMTPConfig() - +func (app *App) resolveBaseURL() string { baseURL := app.baseURL if baseURL == "" { app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) } - baseURL = strings.TrimRight(baseURL, "/") + return strings.TrimRight(baseURL, "/") +} +func (app *App) eventName() string { event, _ := app.getEvent() - eventName := "the event" if event != nil && event.Name != "" { - eventName = event.Name + return event.Name + } + return "the event" +} + +// sendTicketTokenEmail sends a volunteer token link for a ticket to its participant's email. +func (app *App) sendTicketTokenEmail(tk Ticket) error { + if tk.Code == nil || *tk.Code == "" { + return fmt.Errorf("ticket has no code") + } + if tk.ParticipantID == nil { + return fmt.Errorf("ticket has no participant") + } + p, err := app.getParticipant(*tk.ParticipantID) + if err != nil || p == nil { + return fmt.Errorf("participant not found") + } + if p.Email == "" { + return fmt.Errorf("participant has no email address") } - link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) + cfg := app.loadSMTPConfig() + eventName := app.eventName() + link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code) + name := p.PreferredName + if name == "" { + name = tk.Name + } subject := fmt.Sprintf("Your volunteer link for %s", eventName) body := fmt.Sprintf( "Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n", - a.Name, eventName, *a.VolunteerToken, link, + name, eventName, *tk.Code, link, ) - return sendEmail(cfg, a.Email, subject, body) + return sendEmail(cfg, p.Email, subject, body) +} + +func (app *App) sendConfirmationEmail(to, name, confirmToken string) error { + cfg := app.loadSMTPConfig() + eventName := app.eventName() + link := fmt.Sprintf("%s/confirm/%s", app.resolveBaseURL(), confirmToken) + subject := fmt.Sprintf("Please confirm your email for %s", eventName) + body := fmt.Sprintf( + "Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n", + name, eventName, link, + ) + return sendEmail(cfg, to, subject, body) +} + +func (app *App) sendShiftSignupEmail(to, name, kioskLink string) error { + cfg := app.loadSMTPConfig() + eventName := app.eventName() + subject := fmt.Sprintf("Shift signups are open for %s!", eventName) + body := fmt.Sprintf( + "Hi %s,\n\nShift signups are now open for %s!\n\nUse this link to sign up for available shifts:\n%s\n\nSee you there!\n", + name, eventName, kioskLink, + ) + return sendEmail(cfg, to, subject, body) } 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/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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d613636..38828c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,206 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "dexie": "^4.3.0" + "dexie": "^4.3.0", + "lucide-svelte": "^0.576.0" }, "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,11 +652,28 @@ "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", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -473,7 +684,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -484,7 +694,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -494,14 +703,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -858,11 +1065,17 @@ "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", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -907,25 +1120,151 @@ "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", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, + "node_modules/@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", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -934,36 +1273,142 @@ "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", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "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", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "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", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "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", @@ -978,7 +1423,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, "license": "MIT" }, "node_modules/dexie": { @@ -987,6 +1431,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", @@ -1033,19 +1497,47 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/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,33 +1571,152 @@ "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", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@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", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "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/lucide-svelte": { + "version": "0.576.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.576.0.tgz", + "integrity": "sha512-bm7RCoptI8unoEyo9H9sRHTHgnleuBW8npge05ZtxHkNsDNnO3p/BQEU79sshf4k+MSrjqlWvsCN5vVZtgV7ww==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/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 +1747,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 +1816,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 +1881,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,11 +1911,24 @@ "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", "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1268,6 +1952,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 +1993,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,11 +2154,170 @@ } } }, + "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", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 9ca9526..318a5bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,20 @@ "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" + "dexie": "^4.3.0", + "lucide-svelte": "^0.576.0" } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a2cdf76..ac0957e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,90 +1,164 @@ +{#if updateAvailable} +
+ A new version is available. + +
+{/if} + {#if loading} {:else if kioskToken} - + +{:else if isVolunteerSignup} + +{:else if isConfirmEmail} + {:else if !session} - -{:else if role === 'gate'} - - + +{:else if roles.length === 1 && roles[0] === 'gatekeeper'} + + {:else}
-
{/if} + + diff --git a/frontend/src/api.js b/frontend/src/api.js index c6f6e11..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,8 +17,8 @@ export async function apiFetch(path, options = {}) { const res = await fetch(path, { ...options, headers }) if (res.status === 401) { - await db.session.clear() - window.location.hash = '#/login' + await clearSession() + window.location.pathname = '/login' throw new Error('unauthorized') } return res @@ -48,28 +48,29 @@ 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: { get: () => apiJSON('/api/event'), update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }), }, - attendees: { - list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)), - get: (id) => apiJSON(`/api/attendees/${id}`), - create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }), - update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }), - delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }), - checkIn: (id, opts = {}) => - apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }), - generateTokens: () => - apiJSON('/api/attendees/generate-tokens', { method: 'POST' }), - emailToken: (id) => - apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }), - emailAllTokens: () => - apiJSON('/api/attendees/email-tokens', { method: 'POST' }), + participants: { + list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)), + get: (id) => apiJSON(`/api/participants/${id}`), + create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }), + merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }), + }, + tickets: { + list: () => apiJSON('/api/tickets'), + create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }), + checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }), + generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }), + emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }), + emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }), }, volunteers: { list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)), @@ -77,7 +78,8 @@ 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' }), }, @@ -109,6 +111,21 @@ export const api = { get: () => apiJSON('/api/settings'), update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }), testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }), + toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }), + resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }), + resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }), + resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }), + resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), + resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }), + }, + sso: { + enabled: () => kioskFetch('/api/public/sso-enabled'), + init: () => kioskFetch('/api/sso/init'), + }, + signup: { + config: () => kioskFetch('/api/public/signup-config'), + submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), + confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }), }, import: async (formData) => { const res = await apiFetch('/api/import', { method: 'POST', body: formData }) diff --git a/frontend/src/api.test.js b/frontend/src/api.test.js new file mode 100644 index 0000000..a725f32 --- /dev/null +++ b/frontend/src/api.test.js @@ -0,0 +1,145 @@ +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@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({ email: 'admin@example.com', password: 'pass' }) + }) + + it('participants.list calls correct endpoint', async () => { + const f = mockFetch({ participants: [] }) + await api.participants.list({ search: 'test' }) + expect(f.mock.calls[0][0]).toBe('/api/participants?search=test') + }) + + it('participants.delete uses DELETE method', async () => { + const f = mockFetch({}, 204) + await api.participants.delete(5) + expect(f.mock.calls[0][0]).toBe('/api/participants/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') + }) +}) + +describe('signup methods', () => { + it('signup.config fetches config without auth', async () => { + const f = mockFetch({ departments: [], volunteer_note_label: 'Note' }) + await api.signup.config() + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/signup-config') + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.submit posts form data without auth', async () => { + const f = mockFetch({ ok: true }) + await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' }) + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/signup') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' }) + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.confirm posts token without auth', async () => { + const f = mockFetch({ status: 'confirmed' }) + await api.signup.confirm('abc123') + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/public/confirm') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' }) + expect(opts.headers['Authorization']).toBeUndefined() + }) + + it('signup.submit throws on 400', async () => { + mockFetch({ error: 'preferred name and email are required' }, 400) + await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required') + }) +}) + +describe('settings shift signups', () => { + it('toggleShiftSignups posts open flag', async () => { + await saveSession('tok', { id: 1 }) + const f = mockFetch({ shift_signups_open: true }) + await api.settings.toggleShiftSignups(true) + const [url, opts] = f.mock.calls[0] + expect(url).toBe('/api/settings/shift-signups') + expect(opts.method).toBe('POST') + expect(JSON.parse(opts.body)).toEqual({ open: true }) + }) +}) diff --git a/frontend/src/app.css b/frontend/src/app.css index 464472c..3a685ae 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; } @@ -103,8 +106,15 @@ input, select, textarea { width: 100%; font-family: var(--font); 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; } +.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); } /* Search */ .search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } @@ -129,8 +139,12 @@ 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-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); } +* + .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; } +.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); } @@ -170,8 +184,68 @@ tr:hover td { background: rgba(255,255,255,0.02); } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } +/* Mobile header — hidden on desktop */ +.mobile-header { display: none; } + @media (max-width: 640px) { - .sidebar { display: none; } + .mobile-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + } + .mobile-brand { font-weight: 700; font-size: 1rem; } + .mobile-brand .accent { color: var(--c-accent); } + .hamburger { + display: flex; flex-direction: column; gap: 4px; + background: none; border: none; padding: 4px; cursor: pointer; + } + .hamburger span { + display: block; width: 20px; height: 2px; + background: var(--c-text); border-radius: 1px; + transition: transform var(--transition), opacity var(--transition); + } + .sidebar { + position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; + transform: translateX(-100%); + transition: transform 200ms ease; + } + .sidebar.open { transform: translateX(0); } + .nav-overlay { + position: fixed; inset: 0; z-index: 99; + background: rgba(0,0,0,0.5); + } .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 — 16px prevents iOS auto-zoom on focus */ + input, select, textarea { font-size: 16px; } + .form-grid, .form-grid-3 { grid-template-columns: 1fr !important; } } diff --git a/frontend/src/components/CheckInButton.svelte b/frontend/src/components/CheckInButton.svelte index b3ce533..cbd4bd2 100644 --- a/frontend/src/components/CheckInButton.svelte +++ b/frontend/src/components/CheckInButton.svelte @@ -9,5 +9,5 @@ diff --git a/frontend/src/components/Nav.svelte b/frontend/src/components/Nav.svelte index f1ccede..61015f5 100644 --- a/frontend/src/components/Nav.svelte +++ b/frontend/src/components/Nav.svelte @@ -1,57 +1,54 @@ -