diff --git a/Makefile b/Makefile index a8de0d3..72a39be 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +1,16 @@ -.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) +.PHONY: build frontend-build dev clean build: frontend-build - CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike . + CGO_ENABLED=0 go build -o turnpike . frontend-build: - cd frontend && npm ci && BUILD_ID=$$(git rev-parse --short HEAD) npm run build + cd frontend && npm ci && npm run build dev: @echo "Run in two terminals:" @echo " Terminal 1: go run . --db dev.db" @echo " Terminal 2: cd frontend && npm run dev" -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 71132c4..b526fbe 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,20 @@ # Turnpike -Self-hosted event ticketing and volunteer management. One instance, one event. +Self-hosted event attendee and volunteer management. One instance, one event. Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns. ## Features -- **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 +- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in +- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering +- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required +- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in +- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness +- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate - **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync - **Real-time** — check-ins and changes broadcast live via SSE -- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open +- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms - **Single binary** — Go backend embeds the frontend; no runtime dependencies ## Tech Stack @@ -60,11 +59,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and | Role | Access | |------|--------| -| `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 | +| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts | +| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings | +| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department | +| `gate` | Full-screen check-in UI with QR scanner. No access to other pages | See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation. @@ -92,7 +90,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server ## Documentation -- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule +- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board - [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup ## License diff --git a/auth.go b/auth.go index b675e6f..c2d11af 100644 --- a/auth.go +++ b/auth.go @@ -12,10 +12,10 @@ import ( ) type Claims struct { - ParticipantID int `json:"pid"` - Email string `json:"sub"` - Roles []string `json:"roles"` - DeptIDs []int `json:"dept_ids,omitempty"` + UserID int `json:"uid"` + Username string `json:"sub"` + Role string `json:"role"` + 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(s *User) (string, error) { +func (app *App) signToken(u *User) (string, error) { expiry := time.Duration(app.tokenExpiry) * time.Hour claims := Claims{ - ParticipantID: s.ID, - Email: s.Email, - Roles: s.Roles, - DeptIDs: s.DepartmentIDs, + UserID: u.ID, + Username: u.Username, + Role: u.Role, + DeptIDs: u.DepartmentIDs, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -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 && !hasAnyRole(claims.Roles, roles) { + if len(roles) > 0 && !hasRole(claims.Role, roles) { writeError(w, "forbidden", http.StatusForbidden) return } @@ -97,25 +97,9 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler } } -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 { +func hasRole(role string, allowed []string) bool { + for _, r := range allowed { + if r == role { return true } } diff --git a/auth_test.go b/auth_test.go deleted file mode 100644 index 602c6cf..0000000 --- a/auth_test.go +++ /dev/null @@ -1,126 +0,0 @@ -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 0ec6716..2d7b8d2 100644 --- a/db.go +++ b/db.go @@ -40,6 +40,20 @@ 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, @@ -49,24 +63,43 @@ func migrate(db *sql.DB) error { deleted_at TEXT ); - CREATE TABLE IF NOT EXISTS volunteers ( - 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 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_volunteers_kiosk_code - ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket + ON attendees(name, ticket_id) WHERE deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS volunteers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attendee_id INTEGER REFERENCES attendees(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL, + is_lead INTEGER NOT NULL DEFAULT 0, + checked_in INTEGER NOT NULL DEFAULT 0, + checked_in_at TEXT, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT + ); CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -86,71 +119,52 @@ 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')) - ); `) - return err + if err != nil { + return err + } + return migrateV2(db) +} + +// migrateV2 adds new columns to existing databases without data loss. +func migrateV2(db *sql.DB) error { + addColumnIfMissing(db, "attendees", "volunteer_token TEXT UNIQUE") + addColumnIfMissing(db, "attendees", "party_size INTEGER NOT NULL DEFAULT 1") + addColumnIfMissing(db, "attendees", "checked_in_count INTEGER NOT NULL DEFAULT 0") + addColumnIfMissing(db, "shifts", "position INTEGER NOT NULL DEFAULT 0") + // Widen the uniqueness constraint from name-only to (name, ticket_id). + db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`) + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`) + return nil +} + +func addColumnIfMissing(db *sql.DB, table, colDef string) { + colName := strings.Fields(colDef)[0] + rows, err := db.Query(`PRAGMA table_info("` + table + `")`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk) + if name == colName { + return + } + } + db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef) } // --- Types --- +const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token, + party_size, checked_in, checked_in_count, checked_in_at, checked_in_by, + note, created_at, updated_at, deleted_at` + const shiftCols = `id, department_id, name, day, start_time, end_time, capacity, position, updated_at, deleted_at` const shiftColsS = `s.id, s.department_id, s.name, s.day, s.start_time, s.end_time, s.capacity, s.position, s.updated_at, s.deleted_at` @@ -166,12 +180,30 @@ type Event struct { } type User struct { - 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"` + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + DepartmentIDs []int `json:"department_ids"` + CreatedAt string `json:"created_at"` +} + +type Attendee struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + TicketID string `json:"ticket_id"` + TicketType string `json:"ticket_type"` + VolunteerToken *string `json:"volunteer_token,omitempty"` + PartySize int `json:"party_size"` + CheckedIn bool `json:"checked_in"` + CheckedInCount int `json:"checked_in_count"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + CheckedInBy *int `json:"checked_in_by,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Department struct { @@ -184,56 +216,19 @@ type Department struct { } type Volunteer struct { - 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"` + ID int `json:"id"` + AttendeeID *int `json:"attendee_id,omitempty"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + DepartmentID *int `json:"department_id,omitempty"` + IsLead bool `json:"is_lead"` + CheckedIn bool `json:"checked_in"` + CheckedInAt *string `json:"checked_in_at,omitempty"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at,omitempty"` } type Shift struct { @@ -250,11 +245,10 @@ 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"` - DeletedAt *string `json:"deleted_at"` + VolunteerID int `json:"volunteer_id"` + ShiftID int `json:"shift_id"` + Confirmed bool `json:"confirmed"` + UpdatedAt string `json:"updated_at"` } // --- Event --- @@ -283,45 +277,11 @@ func (app *App) upsertEvent(e Event) error { return err } -// --- Staff (participants with login_enabled) --- +// --- Users --- -func (app *App) getParticipantRoles(participantID int) ([]string, error) { +func (app *App) getUserDeptIDs(userID int) ([]int, error) { rows, err := app.db.Query( - `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, + `SELECT department_id FROM user_departments WHERE user_id = ? ORDER BY department_id`, userID, ) if err != nil { return nil, err @@ -339,13 +299,14 @@ func (app *App) getUserDeptIDs(participantID int) ([]int, error) { return ids, rows.Err() } -func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { - if _, err := app.db.Exec(`DELETE FROM participant_departments WHERE participant_id = ?`, participantID); err != nil { +func (app *App) setUserDeptIDs(userID int, deptIDs []int) error { + _, err := app.db.Exec(`DELETE FROM user_departments WHERE user_id = ?`, userID) + if err != nil { return err } for _, deptID := range deptIDs { if _, err := app.db.Exec( - `INSERT INTO participant_departments (participant_id, department_id) VALUES (?, ?)`, participantID, deptID, + `INSERT INTO user_departments (user_id, department_id) VALUES (?, ?)`, userID, deptID, ); err != nil { return err } @@ -353,157 +314,98 @@ func (app *App) setUserDeptIDs(participantID int, deptIDs []int) error { return nil } -func (app *App) getLoginParticipant(email string) (*User, string, error) { - var s User - var hash sql.NullString +func (app *App) getUserByUsername(username string) (*User, string, error) { + var u User + var hash string err := app.db.QueryRow( - `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) + `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &hash, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, "", nil } if err != nil { return nil, "", err } - 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 + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, hash, err } -func (app *App) getUser(id int) (*User, error) { - var s User +func (app *App) getUserByID(id int) (*User, error) { + var u User err := app.db.QueryRow( - `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) + `SELECT id, username, role, created_at FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } - s.Roles, _ = app.getParticipantRoles(s.ID) - s.DepartmentIDs, _ = app.getUserDeptIDs(s.ID) - return &s, nil + u.DepartmentIDs, err = app.getUserDeptIDs(u.ID) + return &u, err } func (app *App) listUsers() ([]User, error) { rows, err := app.db.Query( - `SELECT id, email, preferred_name, created_at - FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL ORDER BY preferred_name, email`, + `SELECT id, username, role, created_at FROM users ORDER BY username`, ) if err != nil { return nil, err } defer rows.Close() - var staff []User + var users []User for rows.Next() { - var s User - if err := rows.Scan(&s.ID, &s.Email, &s.PreferredName, &s.CreatedAt); err != nil { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil { return nil, err } - s.Roles = []string{} - s.DepartmentIDs = []int{} - staff = append(staff, s) + u.DepartmentIDs = []int{} + users = append(users, u) } if err := rows.Err(); err != nil { return nil, err } - for i := range staff { - staff[i].Roles, _ = app.getParticipantRoles(staff[i].ID) - staff[i].DepartmentIDs, _ = app.getUserDeptIDs(staff[i].ID) + for i := range users { + users[i].DepartmentIDs, _ = app.getUserDeptIDs(users[i].ID) } - return staff, nil + return users, nil } -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. +func (app *App) createUser(username, hash, role string, deptIDs []int) (*User, error) { res, err := app.db.Exec( - `INSERT INTO participants (email, preferred_name, password_hash, login_enabled, updated_at) - VALUES (?, ?, ?, 1, ?)`, - strings.ToLower(email), preferredName, hash, now(), + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, + username, hash, role, ) 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.getUser(int(id)) + return app.getUserByID(int(id)) } -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 { +func (app *App) updateUser(id int, role string, deptIDs []int) error { + if _, err := app.db.Exec(`UPDATE users SET role = ? WHERE id = ?`, role, id); err != nil { return err } return app.setUserDeptIDs(id, deptIDs) } func (app *App) updateUserPassword(id int, hash string) error { - _, err := app.db.Exec( - `UPDATE participants SET password_hash = ?, updated_at = ? WHERE id = ? AND login_enabled = 1`, hash, now(), id, - ) + _, err := app.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, 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) deleteUser(id int) error { + _, err := app.db.Exec(`DELETE FROM users WHERE id = ?`, id) + return err } func (app *App) countUsers() (int, error) { var n int - err := app.db.QueryRow(`SELECT COUNT(*) FROM participants WHERE login_enabled = 1 AND deleted_at IS NULL`).Scan(&n) + err := app.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } @@ -511,28 +413,21 @@ func (app *App) countUsers() (int, error) { const tokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -func generateToken() (string, error) { +func generateToken() string { b := make([]byte, 8) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("read random: %w", err) - } + rand.Read(b) result := make([]byte, 8) for i, v := range b { result[i] = tokenChars[int(v)%len(tokenChars)] } - return string(result), nil + return string(result) } func (app *App) generateUniqueToken() (string, error) { for range 10 { - t, err := generateToken() - if err != nil { - return "", err - } + t := generateToken() var count int - if err := app.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE code = ?`, t).Scan(&count); err != nil { - return "", fmt.Errorf("check token uniqueness: %w", err) - } + app.db.QueryRow(`SELECT COUNT(*) FROM attendees WHERE volunteer_token = ?`, t).Scan(&count) if count == 0 { return t, nil } @@ -540,23 +435,30 @@ func (app *App) generateUniqueToken() (string, error) { return "", fmt.Errorf("failed to generate unique token") } -// generateCodesForAll generates codes for every ticket that doesn't have one yet. -func (app *App) generateCodesForAll() (int, error) { +func (app *App) getAttendeeByToken(token string) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE volunteer_token = ? AND deleted_at IS NULL`, token) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +// generateTokensForAll creates tokens for every attendee that doesn't have one yet. +func (app *App) generateTokensForAll() (int, error) { rows, err := app.db.Query( - `SELECT id FROM tickets WHERE code IS NULL AND deleted_at IS NULL`, + `SELECT id FROM attendees WHERE volunteer_token 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 - if err := rows.Scan(&id); err != nil { - return 0, fmt.Errorf("scan ticket id: %w", err) - } + rows.Scan(&id) ids = append(ids, id) } + rows.Close() count := 0 for _, id := range ids { @@ -564,256 +466,161 @@ func (app *App) generateCodesForAll() (int, error) { if err != nil { continue } - app.db.Exec(`UPDATE tickets SET code=?, updated_at=? WHERE id=?`, t, now(), id) + app.db.Exec(`UPDATE attendees SET volunteer_token=?, updated_at=? WHERE id=?`, t, now(), id) count++ } return count, nil } -// --- Participants --- - -const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, email_confirmed, confirmation_token, created_at, updated_at, deleted_at` - -func (app *App) listParticipants(search, since string) ([]Participant, error) { - var q string - var args []any - 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` - } - return queryParticipants(app.db, q, args...) -} - -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) 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) { +// 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( - `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(), + `UPDATE attendees SET party_size = party_size + 1, updated_at = ? + WHERE name = ? AND ticket_id = ? AND deleted_at IS NULL`, + now(), name, ticketID, + ) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// --- Attendees --- + +func (app *App) listAttendees(search, ticketType, checkedIn string) ([]Attendee, error) { + q := `SELECT ` + attendeeCols + ` FROM attendees WHERE deleted_at IS NULL` + var args []any + if search != "" { + q += ` AND (name LIKE ? OR email LIKE ? OR ticket_id LIKE ?)` + s := "%" + search + "%" + args = append(args, s, s, s) + } + if ticketType != "" { + q += ` AND ticket_type = ?` + args = append(args, ticketType) + } + if checkedIn == "true" { + q += ` AND checked_in = 1` + } else if checkedIn == "false" { + q += ` AND checked_in = 0` + } + q += ` ORDER BY name ASC` + return queryAttendees(app.db, q, args...) +} + +func (app *App) getAttendee(id int) (*Attendee, error) { + rows, err := queryAttendees(app.db, + `SELECT `+attendeeCols+` FROM attendees WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) createAttendee(a Attendee) (*Attendee, error) { + res, err := app.db.Exec( + `INSERT INTO attendees (name, email, phone, ticket_id, ticket_type, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), ) if err != nil { return nil, err } id, _ := res.LastInsertId() - return app.getParticipant(int(id)) + return app.getAttendee(int(id)) } -func (app *App) updateParticipant(p Participant) error { +func (app *App) updateAttendee(a Attendee) error { _, err := app.db.Exec( - `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, + `UPDATE attendees SET name=?, email=?, phone=?, ticket_id=?, ticket_type=?, note=?, updated_at=? + WHERE id = ? AND deleted_at IS NULL`, + a.Name, a.Email, a.Phone, a.TicketID, a.TicketType, a.Note, now(), a.ID, ) return err } -func (app *App) deleteParticipant(id int) error { +func (app *App) deleteAttendee(id int) error { _, err := app.db.Exec( - `UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, now(), now(), id, + `UPDATE attendees SET deleted_at = ?, updated_at = ? WHERE id = ?`, now(), now(), id, ) return err } -// 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 +// 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 } - 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 - } - 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, - ) - return err -} - -func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error) { - rows, err := db.Query(q, args...) - if err != nil { + a, err := app.getAttendee(id) + if err != nil || a == nil { return nil, err } - defer rows.Close() - var result []Participant - for rows.Next() { - var p Participant - var emailConfirmed int - var confirmationToken sql.NullString - if err := rows.Scan( - &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note, - &emailConfirmed, &confirmationToken, - &p.CreatedAt, &p.UpdatedAt, &p.DeletedAt, - ); err != nil { - return nil, err - } - p.EmailConfirmed = emailConfirmed == 1 - if confirmationToken.Valid { - p.ConfirmationToken = &confirmationToken.String - } - result = append(result, p) + remaining := a.PartySize - a.CheckedInCount + if count > remaining { + count = remaining } - return result, rows.Err() -} - -// 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 count <= 0 { + return a, nil } - 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 = ? + _, 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`, - t, userID, t, id, + count, t, userID, t, id, ) if err != nil { return nil, err } - return app.getTicket(id) + return app.getAttendee(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 (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 queryTickets(db *sql.DB, q string, args ...any) ([]Ticket, error) { +func queryAttendees(db *sql.DB, q string, args ...any) ([]Attendee, error) { rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() - var result []Ticket + var result []Attendee for rows.Next() { - var t Ticket - var participantID, checkedInBy sql.NullInt64 - var code sql.NullString + var a Attendee + var checkedIn int + var token 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, + &a.ID, &a.Name, &a.Email, &a.Phone, &a.TicketID, &a.TicketType, + &token, &a.PartySize, &checkedIn, &a.CheckedInCount, + &a.CheckedInAt, &a.CheckedInBy, &a.Note, + &a.CreatedAt, &a.UpdatedAt, &a.DeletedAt, ); err != nil { return nil, err } - if participantID.Valid { - id := int(participantID.Int64) - t.ParticipantID = &id + if token.Valid && token.String != "" { + a.VolunteerToken = &token.String } - if checkedInBy.Valid { - id := int(checkedInBy.Int64) - t.CheckedInBy = &id + a.CheckedIn = checkedIn == 1 + if a.PartySize < 1 { + a.PartySize = 1 } - if code.Valid && code.String != "" { - t.Code = &code.String - } - result = append(result, t) + result = append(result, a) } 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) { +func (app *App) attendeeTicketTypes() ([]string, error) { rows, err := app.db.Query( - `SELECT DISTINCT ticket_type FROM tickets WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, + `SELECT DISTINCT ticket_type FROM attendees WHERE ticket_type != '' AND deleted_at IS NULL ORDER BY ticket_type`, ) if err != nil { return nil, err @@ -828,6 +635,12 @@ func (app *App) ticketTypes() ([]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) { @@ -895,44 +708,42 @@ func queryDepartments(db *sql.DB, q string, args ...any) ([]Department, error) { // --- Volunteers --- -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` +const volunteerCols = `id, attendee_id, name, email, phone, department_id, is_lead, checked_in, checked_in_at, note, created_at, updated_at, deleted_at` -func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) { - q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1` +func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) { + q := `SELECT ` + volunteerCols + ` FROM volunteers WHERE 1=1` var args []any if since != "" { - q += ` AND v.updated_at > ?` + q += ` AND updated_at > ?` args = append(args, since) } else { - q += ` AND v.deleted_at IS NULL` + q += ` AND deleted_at IS NULL` } if search != "" { - q += ` AND (p.preferred_name LIKE ? OR p.email LIKE ?)` + q += ` AND (name LIKE ? OR email LIKE ?)` s := "%" + search + "%" args = append(args, s, s) } - 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) - } + if deptID != nil { + q += ` AND department_id = ?` + args = append(args, *deptID) } - q += ` ORDER BY p.preferred_name` + q += ` ORDER BY name` return queryVolunteers(app.db, q, args...) } func (app *App) getVolunteer(id int) (*Volunteer, error) { rows, err := queryVolunteers(app.db, - `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.id = ?`, id) + `SELECT `+volunteerCols+` FROM volunteers WHERE id = ?`, id) + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +func (app *App) getVolunteerByAttendeeID(attendeeID int) (*Volunteer, error) { + rows, err := queryVolunteers(app.db, + `SELECT `+volunteerCols+` FROM volunteers WHERE attendee_id = ? AND deleted_at IS NULL LIMIT 1`, attendeeID) if err != nil || len(rows) == 0 { return nil, err } @@ -941,9 +752,9 @@ func (app *App) getVolunteer(id int) (*Volunteer, error) { func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { res, err := app.db.Exec( - `INSERT INTO volunteers (participant_id, department_id, is_lead, note, updated_at) - VALUES (?, ?, ?, ?, ?)`, - v.ParticipantID, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), + `INSERT INTO volunteers (attendee_id, name, email, phone, department_id, is_lead, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), ) if err != nil { return nil, err @@ -954,9 +765,9 @@ func (app *App) createVolunteer(v Volunteer) (*Volunteer, error) { func (app *App) updateVolunteer(v Volunteer) error { _, err := app.db.Exec( - `UPDATE volunteers SET department_id=?, is_lead=?, note=?, updated_at=? + `UPDATE volunteers SET attendee_id=?, name=?, email=?, phone=?, department_id=?, is_lead=?, note=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, + v.AttendeeID, v.Name, v.Email, v.Phone, v.DepartmentID, boolInt(v.IsLead), v.Note, now(), v.ID, ) return err } @@ -968,30 +779,26 @@ func (app *App) deleteVolunteer(id int) error { return err } -func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) { +// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee, +// also increments the attendee's checked_in_count. +func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) { t := now() _, err := app.db.Exec( - `UPDATE volunteers SET ready=1, ready_at=?, updated_at=? - WHERE id=? AND deleted_at IS NULL AND ready=0`, + `UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=? + WHERE id=? AND deleted_at IS NULL AND checked_in=0`, t, t, id, ) if err != nil { return nil, err } - 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 + v, err := app.getVolunteer(id) + if err != nil || v == nil { + return v, err } - return app.getVolunteer(id) + if v.AttendeeID != nil { + app.checkInAttendee(*v.AttendeeID, userID, 1) + } + return v, nil } func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { @@ -1003,119 +810,33 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) { var result []Volunteer for rows.Next() { var v Volunteer - var deptID sql.NullInt64 - var isLead, ready, confirmed, emailConfirmed int - var confirmedAt, kioskCode sql.NullString + var attendeeID, deptID sql.NullInt64 + var isLead, checkedIn int if err := rows.Scan( - &v.ID, &v.ParticipantID, - &v.Name, &v.Email, &v.Phone, &v.Pronouns, - &deptID, &isLead, &ready, &v.ReadyAt, - &confirmed, &confirmedAt, - &emailConfirmed, &kioskCode, &v.Note, + &v.ID, &attendeeID, &v.Name, &v.Email, &v.Phone, &deptID, + &isLead, &checkedIn, &v.CheckedInAt, &v.Note, &v.CreatedAt, &v.UpdatedAt, &v.DeletedAt, ); err != nil { return nil, err } + if attendeeID.Valid { + id := int(attendeeID.Int64) + v.AttendeeID = &id + } if deptID.Valid { id := int(deptID.Int64) v.DepartmentID = &id } - if confirmedAt.Valid { - v.ConfirmedAt = &confirmedAt.String - } - if kioskCode.Valid { - v.KioskCode = &kioskCode.String - } v.IsLead = isLead == 1 - v.Ready = ready == 1 - v.Confirmed = confirmed == 1 - v.EmailConfirmed = emailConfirmed == 1 + v.CheckedIn = checkedIn == 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(deptIDs []int, day, since string) ([]Shift, error) { +func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) { q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1` var args []any if since != "" { @@ -1124,14 +845,9 @@ func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) { } else { q += ` AND deleted_at IS NULL` } - if len(deptIDs) == 1 { + if deptID != nil { q += ` AND department_id = ?` - 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) - } + args = append(args, *deptID) } if day != "" { q += ` AND day = ?` @@ -1196,7 +912,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 = ? AND deleted_at IS NULL`, shiftID).Scan(&count) + err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteer_shifts WHERE shift_id = ?`, shiftID).Scan(&count) return count, err } @@ -1211,40 +927,21 @@ 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 vs.deleted_at IS NULL AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, + WHERE vs.volunteer_id = ? AND s.day = ? AND s.id != ? AND s.deleted_at IS NULL`, volunteerID, target.Day, shiftID) if err != nil { return nil, err } var conflicts []Shift for _, s := range existing { - if timesOverlap(s.StartTime, s.EndTime, target.StartTime, target.EndTime) { + // Overlap: one starts before the other ends (HH:MM string comparison works for same-day) + if s.StartTime < target.EndTime && target.StartTime < s.EndTime { conflicts = append(conflicts, s) } } return conflicts, nil } -// 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 { @@ -1262,48 +959,15 @@ 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, deleted_at=NULL, updated_at=excluded.updated_at`, + ON CONFLICT(volunteer_id, shift_id) DO UPDATE SET confirmed=1, 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( - `UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE volunteer_id=? AND shift_id=?`, - now(), now(), volunteerID, shiftID, + `DELETE FROM volunteer_shifts WHERE volunteer_id=? AND shift_id=?`, volunteerID, shiftID, ) return err } @@ -1312,10 +976,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, deleted_at FROM volunteer_shifts WHERE updated_at > ?` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts WHERE updated_at > ?` args = append(args, since) } else { - q = `SELECT volunteer_id, shift_id, confirmed, updated_at, deleted_at FROM volunteer_shifts WHERE deleted_at IS NULL` + q = `SELECT volunteer_id, shift_id, confirmed, updated_at FROM volunteer_shifts` } rows, err := app.db.Query(q, args...) if err != nil { @@ -1326,7 +990,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, &vs.DeletedAt) + rows.Scan(&vs.VolunteerID, &vs.ShiftID, &confirmed, &vs.UpdatedAt) vs.Confirmed = confirmed == 1 result = append(result, vs) } @@ -1339,7 +1003,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 vs.deleted_at IS NULL AND s.deleted_at IS NULL + WHERE vs.volunteer_id = ? AND s.deleted_at IS NULL ORDER BY s.day, s.position, s.start_time`, volunteerID) } @@ -1350,32 +1014,11 @@ 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 AND vs.deleted_at IS NULL + SELECT COUNT(*) FROM volunteer_shifts vs WHERE vs.shift_id = s.id ) < 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 { @@ -1388,10 +1031,3 @@ 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 deleted file mode 100644 index 5755d08..0000000 --- a/db_test.go +++ /dev/null @@ -1,186 +0,0 @@ -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 9bc0dbc..1f9a967 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -105,27 +105,23 @@ docker run -p 8180:8180 \ ## NixOS -Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build: +Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO): ```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"; - src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; }; - vendorHash = "sha256-..."; + version = "0.1.0"; + src = ./path/to/turnpike; # must include vendor/ and frontend/dist/ + vendorHash = null; env.CGO_ENABLED = 0; - preBuild = "cp -r ${frontendDist} frontend/dist"; }; ``` -A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`. +The source directory must contain: +- Go source files and `vendor/` (run `go mod vendor`) +- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`) + +A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`. ## Reverse Proxy diff --git a/docs/USAGE.md b/docs/USAGE.md index c08ec50..80b25f0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -12,22 +12,23 @@ 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: 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 | +| **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 | -Coleads are scoped to one or more departments. When creating a colead user, assign their department(s). +Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions. + +Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department. ## Event Setup -1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page. +1. **Configure your event** — go to the Dashboard and set the event name and dates. 2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT). -3. **Import participants** — see next section. -4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity. +3. **Import attendees** — see next section. +4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity. -## Importing Participants +## Importing Attendees Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: @@ -35,7 +36,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `Patron Name` | Ticket name | +| `Patron Name` | Name | | `Patron Email` | Email | | `Order Number` | Ticket ID | | `Tier Name` | Ticket type | @@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats: | Column | Maps to | |--------|---------| -| `name` (required) | Ticket name | +| `name` (required) | Name | | `email` | Email | | `ticket_id` | Ticket ID | | `ticket_type` | Ticket type | @@ -52,67 +53,32 @@ 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. -### Participants and tickets +### Party-size dedup -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). +CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically: -Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated. +- 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 -## Volunteer Signup +The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates). -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. +Re-importing the same CSV is safe — existing records are skipped, not duplicated. ## Managing Volunteers Under **Volunteers**, you can: -- Create volunteers manually (name, email, department, 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) +- 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 -### 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. +Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously. ## Shift Scheduling -Under **Schedule**, create shifts for each department: +Under **Shifts**, create shifts for each department: - **Day** — the date of the shift - **Start/end time** — HH:MM format @@ -120,29 +86,27 @@ Under **Schedule**, create shifts for each department: ### Assigning volunteers -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. +From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment. ### Reordering -Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card. +Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering. ## Volunteer Kiosk -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. +The kiosk lets volunteers self-select shifts without logging in. ### Setup -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. +1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one. +2. **Distribute tokens** — two options: + - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform. + - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee. +3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL. ### Volunteer experience -Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing: +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 @@ -150,45 +114,43 @@ Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. T 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 kiosk code authenticates the request. +No login is required. The 8-character token authenticates the request. -### Code format +### Token format -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). +Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges). -## Gate Kiosk +## Gate Check-In -Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk: +Users with the **gate** role see a dedicated full-screen UI: - **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field. -- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline). +- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline). +- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining." +- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously. - **Recent check-ins** — the last 10 check-ins are shown for quick reference. -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 +## Schedule Board -The Schedule page is the primary UI for managing shifts and volunteer assignments. It shows: +The Schedule Board is the primary UI for coordinators and volunteer leads. It shows: - Shifts grouped by department and day - Each shift card shows: name, time, capacity (used/total), assigned volunteers - Conflict badges when a volunteer has overlapping shifts on the same day -**Admins and staffing** see all departments. **Coleads** see only their assigned department(s). +**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department. 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 volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only): +SMTP enables token email distribution and test emails. Configure in **Settings** (admin only): | Field | Description | |-------|-------------| @@ -209,13 +171,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. +- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order. Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience. ## CSV Exports -CSV exports are available from the Participants page: +Two CSV exports are available from the Attendees page: -- **Participant export** — all participant records with check-in status -- **Ticket export** — all ticket records with codes and check-in status +- **Attendee export** — all attendee records with check-in status +- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns. diff --git a/email.go b/email.go index 41a7a55..05c94f0 100644 --- a/email.go +++ b/email.go @@ -106,73 +106,35 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error { return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg)) } -func (app *App) resolveBaseURL() string { +// sendTokenEmail sends a volunteer token link to the attendee's email address. +func (app *App) sendTokenEmail(a Attendee) error { + if a.Email == "" { + return fmt.Errorf("attendee has no email address") + } + if a.VolunteerToken == nil || *a.VolunteerToken == "" { + return fmt.Errorf("attendee has no volunteer token") + } + + cfg := app.loadSMTPConfig() + baseURL := app.baseURL if baseURL == "" { app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL) } - return strings.TrimRight(baseURL, "/") -} + baseURL = strings.TrimRight(baseURL, "/") -func (app *App) eventName() string { event, _ := app.getEvent() + eventName := "the event" if event != nil && 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") + eventName = event.Name } - cfg := app.loadSMTPConfig() - eventName := app.eventName() - link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code) - name := p.PreferredName - if name == "" { - name = tk.Name - } + link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken) subject := fmt.Sprintf("Your volunteer link for %s", eventName) body := fmt.Sprintf( "Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n", - name, eventName, *tk.Code, link, + a.Name, eventName, *a.VolunteerToken, link, ) - 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) + return sendEmail(cfg, a.Email, subject, body) } diff --git a/frontend/.gitignore b/frontend/.gitignore index 7e445ac..a547bf3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,7 +8,6 @@ pnpm-debug.log* lerna-debug.log* node_modules -.vite dist dist-ssr *.local diff --git a/frontend/README.md b/frontend/README.md index 36b1fa2..54a2631 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,34 +1,43 @@ -# Turnpike Frontend +# Svelte + Vite -Svelte 5 + Vite PWA. Offline-first with Dexie (IndexedDB) and background sync. +This template should help get you started developing with Svelte in Vite. -## Development +## Recommended IDE Setup -From the repo root with `direnv allow` (or Node.js 18+ installed): +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). -```sh -cd frontend -npm install -npm run dev +## 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) ``` - -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 38828c4..d613636 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,206 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "dexie": "^4.3.0", - "lucide-svelte": "^0.576.0" + "dexie": "^4.3.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", - "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" + "vite": "^7.3.1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -652,28 +458,11 @@ "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", @@ -684,6 +473,7 @@ "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", @@ -694,6 +484,7 @@ "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" @@ -703,12 +494,14 @@ "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", @@ -1065,17 +858,11 @@ "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" @@ -1120,151 +907,25 @@ "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" @@ -1273,142 +934,36 @@ "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", @@ -1423,6 +978,7 @@ "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": { @@ -1431,26 +987,6 @@ "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", @@ -1497,47 +1033,19 @@ "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", @@ -1571,152 +1079,33 @@ "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==", - "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 - } + "@types/estree": "^1.0.6" } }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "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" - } + "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/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", @@ -1747,26 +1136,6 @@ ], "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", @@ -1816,26 +1185,6 @@ "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", @@ -1881,26 +1230,6 @@ "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", @@ -1911,24 +1240,11 @@ "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", @@ -1952,30 +1268,6 @@ "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", @@ -1993,72 +1285,6 @@ "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", @@ -2154,170 +1380,11 @@ } } }, - "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 318a5bc..9ca9526 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,20 +6,14 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest" + "preview": "vite preview" }, "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", - "vitest": "^4.0.18" + "vite": "^7.3.1" }, "dependencies": { - "dexie": "^4.3.0", - "lucide-svelte": "^0.576.0" + "dexie": "^4.3.0" } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index ac0957e..a2cdf76 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,164 +1,90 @@ -{#if updateAvailable} -
-{/if} - {#if loading} {:else if kioskToken} -