Compare commits
No commits in common. "trunk" and "0.4.2" have entirely different histories.
57 changed files with 1880 additions and 5110 deletions
18
Makefile
18
Makefile
|
|
@ -1,9 +1,9 @@
|
|||
.PHONY: build frontend-build dev clean test patch minor major
|
||||
|
||||
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
||||
MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
|
||||
MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
|
||||
PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
|
||||
MAJOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f1)
|
||||
MINOR := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f2)
|
||||
PATCH := $(shell echo $(LAST_TAG) | sed 's/^v//' | cut -d. -f3)
|
||||
|
||||
build: frontend-build
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
||||
|
|
@ -25,13 +25,13 @@ clean:
|
|||
rm -rf frontend/dist
|
||||
|
||||
patch:
|
||||
git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
||||
@echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
||||
git tag v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
||||
@echo "Tagged v$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
||||
|
||||
minor:
|
||||
git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
||||
@echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
||||
git tag v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
||||
@echo "Tagged v$(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
||||
|
||||
major:
|
||||
git tag $(shell echo $$(($(MAJOR)+1))).0.0
|
||||
@echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
|
||||
git tag v$(shell echo $$(($(MAJOR)+1))).0.0
|
||||
@echo "Tagged v$(shell echo $$(($(MAJOR)+1))).0.0"
|
||||
|
|
|
|||
28
README.md
28
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
|
||||
|
|
|
|||
40
auth.go
40
auth.go
|
|
@ -12,9 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type Claims struct {
|
||||
ParticipantID int `json:"pid"`
|
||||
Email string `json:"sub"`
|
||||
Roles []string `json:"roles"`
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
auth_test.go
15
auth_test.go
|
|
@ -12,7 +12,7 @@ func TestLoginValid(t *testing.T) {
|
|||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": admin.Email,
|
||||
"username": admin.Username,
|
||||
"password": "admin123",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
|
@ -26,7 +26,7 @@ func TestLoginValid(t *testing.T) {
|
|||
t.Error("missing token in response")
|
||||
}
|
||||
user, ok := result["user"].(map[string]any)
|
||||
if !ok || user["email"] != "oberon@athens.example" {
|
||||
if !ok || user["username"] != "admin" {
|
||||
t.Errorf("user = %v", result["user"])
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ func TestLoginWrongPassword(t *testing.T) {
|
|||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": "oberon@athens.example",
|
||||
"username": "admin",
|
||||
"password": "wrong",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
|
@ -53,7 +53,7 @@ func TestLoginNonexistentUser(t *testing.T) {
|
|||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": "nobody@test.com",
|
||||
"username": "nobody",
|
||||
"password": "test",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
|
@ -94,7 +94,8 @@ func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
|
|||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
|
||||
// Create a gate user — should not be able to access /api/users (admin only)
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
|
||||
req := testAuthRequest("GET", "/api/users", nil, token)
|
||||
|
|
@ -120,7 +121,7 @@ func TestMeEndpoint(t *testing.T) {
|
|||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["email"] != "oberon@athens.example" {
|
||||
t.Errorf("email = %v", result["email"])
|
||||
if result["username"] != "admin" {
|
||||
t.Errorf("username = %v", result["username"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
db_test.go
103
db_test.go
|
|
@ -7,7 +7,7 @@ import (
|
|||
func TestMigrate(t *testing.T) {
|
||||
app := testApp(t)
|
||||
// Verify tables exist by querying each one
|
||||
tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
||||
tables := []string{"event", "users", "attendees", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
||||
for _, table := range tables {
|
||||
var count int
|
||||
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
|
||||
|
|
@ -17,6 +17,98 @@ func TestMigrate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAttendeesCRUD(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
a, err := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com", TicketType: "GA"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.ID == 0 || a.Name != "Titania" {
|
||||
t.Errorf("create: got %+v", a)
|
||||
}
|
||||
|
||||
got, err := app.getAttendee(a.ID)
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("get: not found")
|
||||
}
|
||||
if got.Email != "titania@test.com" {
|
||||
t.Errorf("get: email = %q", got.Email)
|
||||
}
|
||||
|
||||
got.Name = "Titania Fairweather"
|
||||
if err := app.updateAttendee(*got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got2, _ := app.getAttendee(a.ID)
|
||||
if got2.Name != "Titania Fairweather" {
|
||||
t.Errorf("update: name = %q", got2.Name)
|
||||
}
|
||||
|
||||
if err := app.deleteAttendee(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// getAttendee returns soft-deleted records; listAttendees filters them
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
for _, at := range attendees {
|
||||
if at.ID == a.ID {
|
||||
t.Error("delete: still visible in list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementPartySize(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Oberon", TicketID: "ORD-100"})
|
||||
|
||||
merged, err := app.incrementPartySize("Oberon", "ORD-100")
|
||||
if err != nil || !merged {
|
||||
t.Fatalf("increment: merged=%v, err=%v", merged, err)
|
||||
}
|
||||
|
||||
a, _ := app.getAttendee(1)
|
||||
if a.PartySize != 2 {
|
||||
t.Errorf("party_size = %d, want 2", a.PartySize)
|
||||
}
|
||||
|
||||
// Different ticket_id should not merge
|
||||
merged2, _ := app.incrementPartySize("Oberon", "ORD-200")
|
||||
if merged2 {
|
||||
t.Error("should not merge different ticket_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInAttendee(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
// Set party_size directly since createAttendee defaults to 1
|
||||
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
|
||||
|
||||
// Check in 1
|
||||
a, err := app.checkInAttendee(1, admin.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.CheckedInCount != 1 || !a.CheckedIn {
|
||||
t.Errorf("after 1: count=%d, checked_in=%v", a.CheckedInCount, a.CheckedIn)
|
||||
}
|
||||
|
||||
// Check in 2 more (should cap at party_size=3)
|
||||
a, _ = app.checkInAttendee(1, admin.ID, 5)
|
||||
if a.CheckedInCount != 3 {
|
||||
t.Errorf("after cap: count=%d, want 3", a.CheckedInCount)
|
||||
}
|
||||
|
||||
// Check in again — already full, should stay at 3
|
||||
a, _ = app.checkInAttendee(1, admin.ID, 1)
|
||||
if a.CheckedInCount != 3 {
|
||||
t.Errorf("after full: count=%d, want 3", a.CheckedInCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
|
|
@ -105,8 +197,7 @@ func TestAssignAndUnassignShift(t *testing.T) {
|
|||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
v, _ := app.createVolunteer(Volunteer{Name: "Helena", DepartmentID: &deptID})
|
||||
|
||||
if err := app.assignShift(v.ID, s.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -130,8 +221,7 @@ func TestCheckShiftConflict(t *testing.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})
|
||||
v, _ := app.createVolunteer(Volunteer{Name: "Hermia", DepartmentID: &deptID})
|
||||
|
||||
s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
||||
|
|
@ -160,8 +250,7 @@ func TestCheckShiftConflictMidnight(t *testing.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})
|
||||
v, _ := app.createVolunteer(Volunteer{Name: "Lysander", DepartmentID: &deptID})
|
||||
|
||||
// Night shift: 22:00-02:00 (spans midnight)
|
||||
night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
142
docs/USAGE.md
142
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.
|
||||
|
|
|
|||
72
email.go
72
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { getSession, saveSession, clearSession } from './db.js'
|
||||
import { getSession, clearSession } from './db.js'
|
||||
import { syncPull, startSSE, startSyncLoop } from './sync.js'
|
||||
import Login from './pages/Login.svelte'
|
||||
import Dashboard from './pages/Dashboard.svelte'
|
||||
import Participants from './pages/Participants.svelte'
|
||||
import Attendees from './pages/Attendees.svelte'
|
||||
import Volunteers from './pages/Volunteers.svelte'
|
||||
import Departments from './pages/Departments.svelte'
|
||||
import Shifts from './pages/Shifts.svelte'
|
||||
import Users from './pages/Users.svelte'
|
||||
import Import from './pages/Import.svelte'
|
||||
import VolunteerKiosk from './pages/VolunteerKiosk.svelte'
|
||||
import VolunteerSignup from './pages/VolunteerSignup.svelte'
|
||||
import ConfirmEmail from './pages/ConfirmEmail.svelte'
|
||||
import GateKiosk from './pages/GateKiosk.svelte'
|
||||
import Kiosk from './pages/Kiosk.svelte'
|
||||
import GateUI from './pages/GateUI.svelte'
|
||||
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||
import Settings from './pages/Settings.svelte'
|
||||
import Nav from './components/Nav.svelte'
|
||||
|
|
@ -22,23 +21,12 @@
|
|||
|
||||
let session = $state(null)
|
||||
let loading = $state(true)
|
||||
let route = $state(window.location.pathname)
|
||||
let route = $state(window.location.hash || '#/')
|
||||
let updateAvailable = $state(false)
|
||||
let mobileNavOpen = $state(false)
|
||||
let ssoError = $state('')
|
||||
|
||||
// Check if this is a public page (no auth needed)
|
||||
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
|
||||
const isConfirmEmail = $derived(route.startsWith('/confirm/'))
|
||||
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
|
||||
|
||||
function navigate(path) {
|
||||
history.pushState(null, '', path)
|
||||
route = path
|
||||
mobileNavOpen = false
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
// Check if this is a kiosk token URL before doing anything else
|
||||
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
|
||||
async function checkVersion() {
|
||||
try {
|
||||
|
|
@ -51,45 +39,20 @@
|
|||
onMount(async () => {
|
||||
checkVersion()
|
||||
|
||||
// Public pages don't need auth
|
||||
if (isPublicPage) {
|
||||
// Kiosk pages don't need auth
|
||||
if (kioskToken) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Handle SSO callback in URL fragment
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#sso_token=')) {
|
||||
const token = decodeURIComponent(hash.slice('#sso_token='.length))
|
||||
history.replaceState(null, '', '/')
|
||||
try {
|
||||
const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
if (res.ok) {
|
||||
const user = await res.json()
|
||||
await saveSession(token, user)
|
||||
session = { token, user }
|
||||
} else {
|
||||
ssoError = 'SSO login failed. Please try again.'
|
||||
}
|
||||
} catch {
|
||||
ssoError = 'SSO login failed. Please try again.'
|
||||
}
|
||||
} else if (hash.startsWith('#sso_error=')) {
|
||||
ssoError = decodeURIComponent(hash.slice('#sso_error='.length))
|
||||
history.replaceState(null, '', '/')
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
session = await getSession()
|
||||
}
|
||||
loading = false
|
||||
if (session) {
|
||||
await syncPull()
|
||||
startSSE()
|
||||
startSyncLoop()
|
||||
}
|
||||
window.addEventListener('popstate', () => {
|
||||
route = window.location.pathname
|
||||
window.addEventListener('hashchange', () => {
|
||||
route = window.location.hash || '#/'
|
||||
mobileNavOpen = false
|
||||
})
|
||||
|
||||
|
|
@ -99,18 +62,18 @@
|
|||
|
||||
function onLogin(s) {
|
||||
session = s
|
||||
navigate('/')
|
||||
window.location.hash = '#/'
|
||||
syncPull().then(() => { startSSE(); startSyncLoop() })
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await clearSession()
|
||||
session = null
|
||||
navigate('/login')
|
||||
window.location.hash = '#/login'
|
||||
}
|
||||
|
||||
const path = $derived(route || '/')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
const path = $derived(route.replace(/^#/, '') || '/')
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
</script>
|
||||
|
||||
{#if updateAvailable}
|
||||
|
|
@ -123,23 +86,19 @@
|
|||
{#if loading}
|
||||
<!-- checking session -->
|
||||
{:else if kioskToken}
|
||||
<VolunteerKiosk />
|
||||
{:else if isVolunteerSignup}
|
||||
<VolunteerSignup />
|
||||
{:else if isConfirmEmail}
|
||||
<ConfirmEmail />
|
||||
<Kiosk />
|
||||
{:else if !session}
|
||||
<Login onlogin={onLogin} error={ssoError} />
|
||||
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
||||
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
||||
<GateKiosk {session} {onLogout} />
|
||||
<Login onlogin={onLogin} />
|
||||
{:else if role === 'gate'}
|
||||
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
||||
<GateUI {session} {onLogout} />
|
||||
{:else}
|
||||
<div class="layout">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#if mobileNavOpen}
|
||||
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
|
||||
{/if}
|
||||
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
|
||||
<Nav {session} {onLogout} active={path} open={mobileNavOpen} />
|
||||
<div class="main">
|
||||
<header class="mobile-header">
|
||||
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">
|
||||
|
|
@ -148,17 +107,19 @@
|
|||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
||||
</header>
|
||||
{#if path === '/' || path === ''}
|
||||
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||
{#if role === 'volunteer_lead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} {navigate} />
|
||||
<Dashboard {session} />
|
||||
{/if}
|
||||
{:else if path.startsWith('/participants')}
|
||||
<Participants {session} />
|
||||
{:else if path.startsWith('/attendees')}
|
||||
<Attendees {session} />
|
||||
{:else if path.startsWith('/volunteers')}
|
||||
<Volunteers {session} />
|
||||
{:else if path.startsWith('/departments')}
|
||||
<Departments {session} />
|
||||
{:else if path.startsWith('/shifts')}
|
||||
<Shifts {session} />
|
||||
{:else if path.startsWith('/schedule')}
|
||||
<ScheduleBoard {session} />
|
||||
{:else if path.startsWith('/users')}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { db, clearSession } from './db.js'
|
||||
import { db } from './db.js'
|
||||
|
||||
async function getToken() {
|
||||
const session = await db.session.get(1)
|
||||
|
|
@ -17,8 +17,8 @@ export async function apiFetch(path, options = {}) {
|
|||
|
||||
const res = await fetch(path, { ...options, headers })
|
||||
if (res.status === 401) {
|
||||
await clearSession()
|
||||
window.location.pathname = '/login'
|
||||
await db.session.clear()
|
||||
window.location.hash = '#/login'
|
||||
throw new Error('unauthorized')
|
||||
}
|
||||
return res
|
||||
|
|
@ -48,29 +48,28 @@ async function kioskFetch(path, options = {}) {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
login: (email, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
login: (username, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||
me: () => apiJSON('/api/me'),
|
||||
event: {
|
||||
get: () => apiJSON('/api/event'),
|
||||
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
},
|
||||
participants: {
|
||||
list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)),
|
||||
get: (id) => apiJSON(`/api/participants/${id}`),
|
||||
create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }),
|
||||
merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }),
|
||||
},
|
||||
tickets: {
|
||||
list: () => apiJSON('/api/tickets'),
|
||||
create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }),
|
||||
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
|
||||
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
||||
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
||||
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
|
||||
attendees: {
|
||||
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
|
||||
get: (id) => apiJSON(`/api/attendees/${id}`),
|
||||
create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }),
|
||||
checkIn: (id, opts = {}) =>
|
||||
apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }),
|
||||
generateTokens: () =>
|
||||
apiJSON('/api/attendees/generate-tokens', { method: 'POST' }),
|
||||
emailToken: (id) =>
|
||||
apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }),
|
||||
emailAllTokens: () =>
|
||||
apiJSON('/api/attendees/email-tokens', { method: 'POST' }),
|
||||
},
|
||||
volunteers: {
|
||||
list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)),
|
||||
|
|
@ -78,8 +77,7 @@ export const api = {
|
|||
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||
markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }),
|
||||
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }),
|
||||
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
||||
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
|
||||
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||
},
|
||||
|
|
@ -111,21 +109,6 @@ export const api = {
|
|||
get: () => apiJSON('/api/settings'),
|
||||
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
||||
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
|
||||
resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }),
|
||||
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
|
||||
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
|
||||
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
|
||||
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
|
||||
},
|
||||
sso: {
|
||||
enabled: () => kioskFetch('/api/public/sso-enabled'),
|
||||
init: () => kioskFetch('/api/sso/init'),
|
||||
},
|
||||
signup: {
|
||||
config: () => kioskFetch('/api/public/signup-config'),
|
||||
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
|
||||
confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }),
|
||||
},
|
||||
import: async (formData) => {
|
||||
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
||||
|
|
|
|||
|
|
@ -64,23 +64,23 @@ describe('apiJSON', () => {
|
|||
describe('api methods', () => {
|
||||
it('login calls correct endpoint', async () => {
|
||||
const f = mockFetch({ token: 'tok', user: { id: 1 } })
|
||||
await api.login('admin@example.com', 'pass')
|
||||
await api.login('admin', 'pass')
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/login')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' })
|
||||
expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' })
|
||||
})
|
||||
|
||||
it('participants.list calls correct endpoint', async () => {
|
||||
const f = mockFetch({ participants: [] })
|
||||
await api.participants.list({ search: 'test' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants?search=test')
|
||||
it('attendees.list calls correct endpoint', async () => {
|
||||
const f = mockFetch({ attendees: [] })
|
||||
await api.attendees.list({ search: 'test' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/attendees?search=test')
|
||||
})
|
||||
|
||||
it('participants.delete uses DELETE method', async () => {
|
||||
it('attendees.delete uses DELETE method', async () => {
|
||||
const f = mockFetch({}, 204)
|
||||
await api.participants.delete(5)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants/5')
|
||||
await api.attendees.delete(5)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/attendees/5')
|
||||
expect(f.mock.calls[0][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
|
|
@ -96,50 +96,3 @@ describe('api methods', () => {
|
|||
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
|
||||
})
|
||||
})
|
||||
|
||||
describe('signup methods', () => {
|
||||
it('signup.config fetches config without auth', async () => {
|
||||
const f = mockFetch({ departments: [], volunteer_note_label: 'Note' })
|
||||
await api.signup.config()
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/signup-config')
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.submit posts form data without auth', async () => {
|
||||
const f = mockFetch({ ok: true })
|
||||
await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/signup')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.confirm posts token without auth', async () => {
|
||||
const f = mockFetch({ status: 'confirmed' })
|
||||
await api.signup.confirm('abc123')
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/confirm')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' })
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.submit throws on 400', async () => {
|
||||
mockFetch({ error: 'preferred name and email are required' }, 400)
|
||||
await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings shift signups', () => {
|
||||
it('toggleShiftSignups posts open flag', async () => {
|
||||
await saveSession('tok', { id: 1 })
|
||||
const f = mockFetch({ shift_signups_open: true })
|
||||
await api.settings.toggleShiftSignups(true)
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/settings/shift-signups')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ open: true })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,9 +66,6 @@ a:hover { color: var(--c-accent-h); }
|
|||
|
||||
/* Cards */
|
||||
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
|
||||
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
|
||||
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
|
||||
|
||||
/* Stats */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
|
|
@ -106,15 +103,8 @@ input, select, textarea {
|
|||
width: 100%; font-family: var(--font);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
input[type="checkbox"] { width: auto; }
|
||||
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
||||
input::placeholder { color: var(--c-muted); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
|
||||
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
|
|
@ -139,12 +129,8 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
|||
font-size: 0.72rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
* + .badge { margin-left: 0.3rem; }
|
||||
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
||||
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
|
||||
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
|
||||
.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||
|
||||
|
|
@ -219,33 +205,4 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
|||
}
|
||||
.page { padding: 1rem; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
/* Touch targets */
|
||||
.btn { min-height: 44px; padding: 0.6rem 1rem; }
|
||||
.btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* Page header & actions */
|
||||
.page-header { flex-wrap: wrap; gap: 0.75rem; }
|
||||
.page-title { width: 100%; }
|
||||
.actions { flex-wrap: wrap; }
|
||||
|
||||
/* Search bar */
|
||||
.search-bar { flex-wrap: wrap; }
|
||||
.search-bar input { max-width: none; flex: 1 1 100%; }
|
||||
|
||||
/* Table → card layout */
|
||||
.table-wrap { overflow-x: visible; }
|
||||
table { display: block; }
|
||||
thead { display: none; }
|
||||
tbody { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center;
|
||||
padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg);
|
||||
background: var(--c-surface); }
|
||||
tr:hover td { background: transparent; }
|
||||
td { display: inline; padding: 0; border: none; }
|
||||
td:empty { display: none; }
|
||||
|
||||
/* Forms — 16px prevents iOS auto-zoom on focus */
|
||||
input, select, textarea { font-size: 16px; }
|
||||
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
</script>
|
||||
|
||||
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||
{loading ? '…' : 'Mark ready'}
|
||||
{loading ? '…' : '✓ Check in'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,53 @@
|
|||
<script>
|
||||
import { LayoutDashboard, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
|
||||
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, Clock, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
|
||||
|
||||
let { session, active, onLogout, navigate, open = false } = $props()
|
||||
let { session, active, onLogout, open = false } = $props()
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
|
||||
const iconProps = { size: 18, strokeWidth: 1.75 }
|
||||
|
||||
const links = $derived.by(() => {
|
||||
if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
if (role === 'ticketing') return [
|
||||
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||
{ href: '#/import', label: 'Import', icon: Upload },
|
||||
]
|
||||
if (!hasRole('admin') && hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
if (role === 'volunteer_lead') return [
|
||||
{ href: '#/', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
||||
]
|
||||
if (role === 'coordinator') return [
|
||||
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
||||
{ href: '#/shifts', label: 'Shifts', icon: Clock },
|
||||
]
|
||||
return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/participants', label: 'Participants', icon: Ticket },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/import', label: 'Import', icon: Upload },
|
||||
{ href: '/users', label: 'Users', icon: Users },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
|
||||
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '#/departments', label: 'Departments', icon: Hexagon },
|
||||
{ href: '#/shifts', label: 'Shifts', icon: Clock },
|
||||
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '#/import', label: 'Import', icon: Upload },
|
||||
{ href: '#/users', label: 'Users', icon: Users },
|
||||
{ href: '#/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
})
|
||||
|
||||
function isActive(href) {
|
||||
if (href === '/') return active === '/' || active === ''
|
||||
return active.startsWith(href)
|
||||
const p = href.replace(/^#/, '')
|
||||
if (p === '/') return active === '/' || active === ''
|
||||
return active.startsWith(p)
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" class:open>
|
||||
<div class="sidebar-brand">Turn<span>pike</span></div>
|
||||
{#each links as link}
|
||||
<a href={link.href} class="nav-link" class:active={isActive(link.href)}
|
||||
onclick={(e) => { e.preventDefault(); navigate(link.href) }}>
|
||||
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
|
||||
<link.icon {...iconProps} />
|
||||
{link.label}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -26,42 +26,6 @@ db.version(2).stores({
|
|||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(3).stores({
|
||||
session: 'id, token, user',
|
||||
meta: 'key',
|
||||
event: 'id',
|
||||
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
|
||||
participants: 'id, email, preferred_name, updated_at, deleted_at',
|
||||
tickets: 'id, participant_id, code, source, checked_in_at, updated_at, deleted_at',
|
||||
departments: 'id, name, deleted_at',
|
||||
volunteers: 'id, name, department_id, checked_in, attendee_id, participant_id, deleted_at',
|
||||
shifts: 'id, department_id, day, position, deleted_at',
|
||||
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
|
||||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(4).stores({
|
||||
attendees: null,
|
||||
outbox: null,
|
||||
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at',
|
||||
})
|
||||
|
||||
db.version(5).stores({
|
||||
volunteers: 'id, participant_id, department_id, deleted_at',
|
||||
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
|
||||
})
|
||||
|
||||
db.version(6).stores({}).upgrade(async tx => {
|
||||
await tx.table('session').clear()
|
||||
await tx.table('meta').clear()
|
||||
await tx.table('participants').clear()
|
||||
await tx.table('tickets').clear()
|
||||
await tx.table('departments').clear()
|
||||
await tx.table('volunteers').clear()
|
||||
await tx.table('shifts').clear()
|
||||
await tx.table('volunteer_shifts').clear()
|
||||
})
|
||||
|
||||
export async function getLastSync() {
|
||||
const m = await db.meta.get('last_sync')
|
||||
return m?.value ?? ''
|
||||
|
|
@ -80,18 +44,6 @@ export async function saveSession(token, user) {
|
|||
}
|
||||
|
||||
export async function clearSession() {
|
||||
await db.transaction('rw',
|
||||
[db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
await db.session.clear()
|
||||
await db.meta.clear()
|
||||
await db.event.clear()
|
||||
await db.participants.clear()
|
||||
await db.tickets.clear()
|
||||
await db.departments.clear()
|
||||
await db.volunteers.clear()
|
||||
await db.shifts.clear()
|
||||
await db.volunteer_shifts.clear()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ describe('db schema', () => {
|
|||
it('has expected tables', () => {
|
||||
const names = db.tables.map(t => t.name).sort()
|
||||
expect(names).toEqual([
|
||||
'departments', 'event', 'meta',
|
||||
'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
|
||||
'attendees', 'departments', 'event', 'meta',
|
||||
'outbox', 'session', 'shifts', 'volunteer_shifts', 'volunteers',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -22,10 +22,10 @@ describe('session', () => {
|
|||
})
|
||||
|
||||
it('saves and retrieves session', async () => {
|
||||
await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] })
|
||||
await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' })
|
||||
const s = await getSession()
|
||||
expect(s.token).toBe('tok123')
|
||||
expect(s.user.email).toBe('admin@example.com')
|
||||
expect(s.user.username).toBe('admin')
|
||||
})
|
||||
|
||||
it('clears session and meta', async () => {
|
||||
|
|
|
|||
274
frontend/src/pages/Attendees.svelte
Normal file
274
frontend/src/pages/Attendees.svelte
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
import CheckInButton from '../components/CheckInButton.svelte'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let filterType = $state('')
|
||||
let filterChecked = $state('')
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
let showAdd = $state(false)
|
||||
let newName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newTicketID = $state('')
|
||||
let newTicketType = $state('')
|
||||
let newNote = $state('')
|
||||
let adding = $state(false)
|
||||
let generating = $state(false)
|
||||
let emailing = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||
const canCheckIn = $derived(['admin', 'ticketing', 'gate'].includes(role))
|
||||
|
||||
const allAttendees = liveQuery(() => db.attendees.toArray())
|
||||
const ticketTypes = liveQuery(() =>
|
||||
db.attendees.orderBy('ticket_type').uniqueKeys()
|
||||
)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const list = $allAttendees ?? []
|
||||
const s = search.toLowerCase()
|
||||
return list
|
||||
.filter(a => {
|
||||
if (filterType && a.ticket_type !== filterType) return false
|
||||
if (filterChecked === 'true' && !a.checked_in) return false
|
||||
if (filterChecked === 'false' && a.checked_in) return false
|
||||
if (s && !a.name.toLowerCase().includes(s) &&
|
||||
!a.email.toLowerCase().includes(s) &&
|
||||
!a.ticket_id.toLowerCase().includes(s)) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
async function checkIn(attendee) {
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id)
|
||||
if (result.attendee) await db.attendees.put(result.attendee)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addAttendee(e) {
|
||||
e.preventDefault()
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const a = await api.attendees.create({
|
||||
name: newName, email: newEmail, phone: newPhone,
|
||||
ticket_id: newTicketID, ticket_type: newTicketType, note: newNote,
|
||||
})
|
||||
await db.attendees.put(a)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newTicketID = newTicketType = newNote = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateTokens() {
|
||||
generating = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.generateTokens()
|
||||
success = `Generated ${result.generated} token${result.generated !== 1 ? 's' : ''}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailAll() {
|
||||
if (!confirm('Send token emails to all attendees with a token and email address?')) return
|
||||
emailing = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.attendees.emailAllTokens()
|
||||
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||
if (result.errors?.length) error = result.errors.join('; ')
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
emailing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailToken(attendee) {
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
await api.attendees.emailToken(attendee.id)
|
||||
success = `Token email sent to ${attendee.name}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Attendees</h1>
|
||||
<div class="actions">
|
||||
{#if canManage}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||
<a href="/api/attendees/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={generateTokens} disabled={generating}>
|
||||
{generating ? '…' : '⚿ Tokens'}
|
||||
</button>
|
||||
<a href="/api/attendees/export-tokens" class="btn btn-ghost btn-sm">Export Links</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||
{emailing ? '…' : '✉ Email All'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="alert alert-success">{success}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addAttendee}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="new-name">Name *</label>
|
||||
<input id="new-name" bind:value={newName} required placeholder="Full name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-email">Email</label>
|
||||
<input id="new-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-id">Ticket ID</label>
|
||||
<input id="new-ticket-id" bind:value={newTicketID} placeholder="From ticketing system" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-ticket-type">Ticket type</label>
|
||||
<input id="new-ticket-type" bind:value={newTicketType} placeholder="e.g. General, VIP" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-note">Note</label>
|
||||
<input id="new-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add attendee'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input placeholder="Search name, email, ticket ID…" bind:value={search} />
|
||||
{#if ($ticketTypes ?? []).length > 0}
|
||||
<select bind:value={filterType} style="width:auto">
|
||||
<option value="">All types</option>
|
||||
{#each $ticketTypes ?? [] as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select bind:value={filterChecked} style="width:auto">
|
||||
<option value="">All</option>
|
||||
<option value="false">Not checked in</option>
|
||||
<option value="true">Checked in</option>
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($allAttendees ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No attendees yet</strong>
|
||||
<p>Import a CSV or add attendees manually.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Ticket type</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
{#if canCheckIn}<th></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as a (a.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
{#if a.ticket_id}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {a.ticket_id}</span>
|
||||
{/if}
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge badge-lead" style="margin-left:0.3rem">×{a.party_size}</span>
|
||||
{/if}
|
||||
{#if a.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{a.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">{a.ticket_type || '—'}</td>
|
||||
<td>
|
||||
<div>{a.email || '—'}</div>
|
||||
{#if a.volunteer_token && canManage}
|
||||
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||
<code style="color:var(--c-accent-h)">{a.volunteer_token}</code>
|
||||
{#if a.email}
|
||||
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||
onclick={() => emailToken(a)}>✉</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if (a.party_size ?? 1) > 1}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in_count ?? 0}/{a.party_size} in
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'Checked in' : 'Pending'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if a.checked_in_at}
|
||||
<div class="text-muted" style="font-size:0.75rem">
|
||||
{new Date(a.checked_in_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{#if canCheckIn}
|
||||
<td>
|
||||
{#if (a.checked_in_count ?? 0) < (a.party_size ?? 1)}
|
||||
<CheckInButton onclick={() => checkIn(a)} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
|
||||
let status = $state('loading')
|
||||
let kioskLink = $state('')
|
||||
let error = $state('')
|
||||
|
||||
onMount(async () => {
|
||||
const match = window.location.pathname.match(/^\/confirm\/(.+)/)
|
||||
const token = match?.[1] ?? ''
|
||||
if (!token) {
|
||||
status = 'invalid'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api.signup.confirm(token)
|
||||
status = result.status ?? 'invalid'
|
||||
if (result.kiosk_link) kioskLink = result.kiosk_link
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
status = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="kiosk">
|
||||
<div class="kiosk-header">
|
||||
<div class="kiosk-brand">Turn<span>pike</span> <span class="kiosk-role">Email Confirmation</span></div>
|
||||
</div>
|
||||
|
||||
<div class="kiosk-body">
|
||||
{#if status === 'loading'}
|
||||
<div class="kiosk-center">Confirming...</div>
|
||||
{:else if status === 'confirmed'}
|
||||
<div class="kiosk-card" style="text-align:center">
|
||||
<div class="confirm-icon">✓</div>
|
||||
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Email Confirmed</h2>
|
||||
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||
Your email address has been verified. Thank you for signing up!
|
||||
</p>
|
||||
{#if kioskLink}
|
||||
<div class="kiosk-link-box">
|
||||
<p style="color:var(--c-text);font-weight:600;margin-bottom:0.5rem">Shift signups are open!</p>
|
||||
<a href={kioskLink} class="kbtn kbtn-primary">Choose Your Shifts</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if status === 'already_confirmed'}
|
||||
<div class="kiosk-card" style="text-align:center">
|
||||
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Already Confirmed</h2>
|
||||
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||
This email address was already confirmed. No further action needed.
|
||||
</p>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div class="kiosk-error">{error}</div>
|
||||
{:else}
|
||||
<div class="kiosk-card" style="text-align:center">
|
||||
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Invalid Link</h2>
|
||||
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||
This confirmation link is not valid or has already been used.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.kiosk-header {
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.kiosk-brand {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.kiosk-brand span:first-of-type { color: var(--c-accent); }
|
||||
.kiosk-role {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--c-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.kiosk-body {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.kiosk-center { display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
|
||||
.kiosk-card {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
.kiosk-error {
|
||||
background: rgba(239,68,68,0.12);
|
||||
border: 1px solid rgba(239,68,68,0.3);
|
||||
color: #fca5a5;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.confirm-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34,197,94,0.15);
|
||||
color: #4ade80;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
.kiosk-link-box {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
.kbtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 0.55rem 1.25rem; border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
font-family: var(--font);
|
||||
text-decoration: none;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.kbtn-primary { background: var(--c-accent); color: #fff; }
|
||||
.kbtn-primary:hover { background: var(--c-accent-h); }
|
||||
</style>
|
||||
|
|
@ -2,57 +2,15 @@
|
|||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
|
||||
let { session, navigate } = $props()
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
const isAdmin = $derived(hasRole('admin'))
|
||||
const isStaffing = $derived(hasRole('admin', 'staffing'))
|
||||
const isColead = $derived(hasRole('colead'))
|
||||
let { session } = $props()
|
||||
|
||||
const attendees = liveQuery(() => db.attendees.toArray())
|
||||
const event = liveQuery(() => db.event.get(1))
|
||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||
const allVolunteers = liveQuery(() => db.volunteers.filter(v => !v.deleted_at).toArray())
|
||||
const allShifts = liveQuery(() => db.shifts.filter(s => !s.deleted_at).toArray())
|
||||
const allDepts = liveQuery(() => db.departments.filter(d => !d.deleted_at).toArray())
|
||||
const allVS = liveQuery(() => db.volunteer_shifts.toArray())
|
||||
|
||||
// Ticket stats
|
||||
const tickets = $derived($allTickets ?? [])
|
||||
const ticketTotal = $derived(tickets.length)
|
||||
const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length)
|
||||
const ticketRemaining = $derived(ticketTotal - ticketCheckedIn)
|
||||
const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0)
|
||||
|
||||
// Volunteer stats (scoped for colead)
|
||||
const volunteers = $derived.by(() => {
|
||||
const vols = $allVolunteers ?? []
|
||||
if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id))
|
||||
return vols
|
||||
})
|
||||
const volTotal = $derived(volunteers.length)
|
||||
const volCheckedIn = $derived(volunteers.filter(v => v.ready).length)
|
||||
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
|
||||
|
||||
// Shift stats (scoped for colead)
|
||||
const shifts = $derived.by(() => {
|
||||
const all = $allShifts ?? []
|
||||
if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id))
|
||||
return all
|
||||
})
|
||||
const shiftTotal = $derived(shifts.length)
|
||||
const shiftsFilled = $derived.by(() => {
|
||||
const vs = $allVS ?? []
|
||||
return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length
|
||||
})
|
||||
const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0)
|
||||
|
||||
// Department names for colead header
|
||||
const myDeptNames = $derived.by(() => {
|
||||
const depts = $allDepts ?? []
|
||||
return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean)
|
||||
})
|
||||
const total = $derived(($attendees ?? []).length)
|
||||
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
|
||||
const remaining = $derived(total - checkedIn)
|
||||
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
|
@ -70,113 +28,35 @@
|
|||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isColead && myDeptNames.length > 0}
|
||||
<p style="margin-bottom:1.5rem;font-size:0.9rem">
|
||||
Your department{myDeptNames.length > 1 ? 's' : ''}:
|
||||
<strong>{myDeptNames.join(', ')}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Ticket check-in (admin) -->
|
||||
{#if isAdmin}
|
||||
<h2 class="dash-section">Ticket Check-in</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total tickets</div>
|
||||
<div class="stat-value">{ticketTotal}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Checked in</div>
|
||||
<div class="stat-value" style="color:var(--c-success)">{ticketCheckedIn}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Remaining</div>
|
||||
<div class="stat-value">{ticketRemaining}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Progress</div>
|
||||
<div class="stat-value">{ticketPct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ticketTotal > 0}
|
||||
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden;margin-bottom:2rem">
|
||||
<div style="height:100%;width:{ticketPct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Volunteer stats (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total</div>
|
||||
<div class="stat-value">{volTotal}</div>
|
||||
<div class="stat-value">{total}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Checked in</div>
|
||||
<div class="stat-value" style="color:var(--c-success)">{volCheckedIn}</div>
|
||||
<div class="stat-value" style="color:var(--c-success)">{checkedIn}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Leads</div>
|
||||
<div class="stat-value">{volLeads}</div>
|
||||
<div class="stat-label">Remaining</div>
|
||||
<div class="stat-value">{remaining}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Progress</div>
|
||||
<div class="stat-value">{pct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if total > 0}
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden">
|
||||
<div style="height:100%;width:{pct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shift coverage (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total shifts</div>
|
||||
<div class="stat-value">{shiftTotal}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">With volunteers</div>
|
||||
<div class="stat-value">{shiftsFilled}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Fill rate</div>
|
||||
<div class="stat-value">{shiftFillPct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick actions -->
|
||||
{#if isAdmin}
|
||||
<div class="dash-actions">
|
||||
<a href="/import" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/import') }}>Import CSV</a>
|
||||
<a href="/participants" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
|
||||
<a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
|
||||
</div>
|
||||
{:else if isStaffing || isColead}
|
||||
<div class="dash-actions">
|
||||
<a href="/schedule" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/schedule') }}>View Schedule</a>
|
||||
<a href="/volunteers" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
|
||||
· {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
|
||||
<p class="text-muted" style="font-size:0.85rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dash-section {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--c-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.dash-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@
|
|||
let editDesc = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canCreate = $derived(hasRole('admin', 'staffing'))
|
||||
const canDelete = $derived(hasRole('admin'))
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canCreate = $derived(['admin', 'coordinator'].includes(role))
|
||||
const canDelete = $derived(role === 'admin')
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
|
|
@ -101,7 +100,7 @@
|
|||
{#if showAdd && canCreate}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addDept}>
|
||||
<div class="form-grid-3">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="d-name">Name *</label>
|
||||
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||
|
|
@ -112,7 +111,7 @@
|
|||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="d-color">Color</label>
|
||||
<input id="d-color" type="color" bind:value={newColor} class="color-input" />
|
||||
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:1rem">
|
||||
|
|
@ -128,7 +127,7 @@
|
|||
{#if ($allDepts ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No departments yet</strong>
|
||||
<p>Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.</p>
|
||||
<p>Add departments to organize your volunteer teams.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
|
|
@ -143,8 +142,8 @@
|
|||
<tbody>
|
||||
{#each $allDepts ?? [] as d (d.id)}
|
||||
{#if editID === d.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||
<input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" />
|
||||
<input bind:value={editName} required placeholder="Name" style="margin:0" />
|
||||
|
|
@ -154,7 +153,7 @@
|
|||
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
||||
</td>
|
||||
{#if canCreate}
|
||||
<td class="td-actions">
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
|
|
@ -166,13 +165,13 @@
|
|||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="td-name">
|
||||
<td>
|
||||
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
||||
<strong>{d.name}</strong>
|
||||
</td>
|
||||
<td class="td-desc text-muted">{d.description || '—'}</td>
|
||||
<td class="text-muted">{d.description || '—'}</td>
|
||||
{#if canCreate}
|
||||
<td class="td-actions">
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
||||
{#if canDelete}
|
||||
|
|
@ -189,13 +188,3 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
|
||||
@media (max-width: 640px) {
|
||||
.td-name { width: 100%; }
|
||||
.td-desc { width: 100%; }
|
||||
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
let { session, onLogout } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let manuallySelectedId = $state(null)
|
||||
let showAll = $state(false)
|
||||
let error = $state('')
|
||||
let scannerMsg = $state('')
|
||||
let qrSupported = $state(false)
|
||||
|
|
@ -18,89 +16,36 @@
|
|||
let detector = $state(null)
|
||||
let scanInterval = $state(null)
|
||||
|
||||
const tickets = liveQuery(() =>
|
||||
db.tickets.filter(t => !t.deleted_at).toArray()
|
||||
)
|
||||
|
||||
const participants = liveQuery(() =>
|
||||
db.participants.filter(p => !p.deleted_at).toArray()
|
||||
const attendees = liveQuery(() =>
|
||||
db.attendees.filter(a => !a.deleted_at).toArray()
|
||||
)
|
||||
|
||||
const recentCheckIns = liveQuery(() =>
|
||||
db.tickets
|
||||
.filter(t => !!t.checked_in_at && !t.deleted_at)
|
||||
db.attendees
|
||||
.filter(a => a.checked_in && !a.deleted_at)
|
||||
.toArray()
|
||||
.then(arr => arr
|
||||
.filter(a => a.checked_in_at)
|
||||
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
||||
.slice(0, 10)
|
||||
)
|
||||
)
|
||||
|
||||
// Exact code/external_id match (QR scan or typed code)
|
||||
const matchedTicket = $derived.by(() => {
|
||||
const s = search.trim()
|
||||
if (!s || s.length < 2) return null
|
||||
const sl = s.toLowerCase()
|
||||
const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl)
|
||||
if (byCode) return byCode
|
||||
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
|
||||
})
|
||||
|
||||
const allParticipantsSorted = $derived.by(() =>
|
||||
($participants ?? [])
|
||||
.filter(p => !p.deleted_at)
|
||||
.sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || ''))
|
||||
)
|
||||
|
||||
// Clear manual selection whenever search text changes
|
||||
$effect(() => {
|
||||
search
|
||||
manuallySelectedId = null
|
||||
})
|
||||
|
||||
// Name/email/ticket-name search across participants
|
||||
const filteredParticipants = $derived.by(() => {
|
||||
if (matchedTicket) return []
|
||||
const filtered = $derived.by(() => {
|
||||
const s = search.trim().toLowerCase()
|
||||
if (!s || s.length < 2) return []
|
||||
const byTicketName = new Set(
|
||||
($tickets ?? [])
|
||||
.filter(t => t.name?.toLowerCase().includes(s))
|
||||
.map(t => t.participant_id)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return ($participants ?? [])
|
||||
.filter(p =>
|
||||
p.preferred_name?.toLowerCase().includes(s) ||
|
||||
p.email?.toLowerCase().includes(s) ||
|
||||
byTicketName.has(p.id)
|
||||
)
|
||||
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
|
||||
return ($attendees ?? [])
|
||||
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
// Manual selection takes priority; fall back to auto-select on single match
|
||||
const selectedParticipant = $derived.by(() => {
|
||||
if (manuallySelectedId) {
|
||||
return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null
|
||||
}
|
||||
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
||||
return null
|
||||
const selected = $derived.by(() => {
|
||||
if (filtered.length === 1) return filtered[0]
|
||||
const s = search.trim().toLowerCase()
|
||||
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
|
||||
})
|
||||
|
||||
function ticketsFor(participantId) {
|
||||
return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at)
|
||||
}
|
||||
|
||||
function participantFor(ticket) {
|
||||
if (!ticket?.participant_id) return null
|
||||
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
|
||||
}
|
||||
|
||||
function nameFor(ticket) {
|
||||
return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)'
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
qrSupported = 'BarcodeDetector' in window
|
||||
})
|
||||
|
|
@ -151,19 +96,40 @@
|
|||
} catch {}
|
||||
}
|
||||
|
||||
async function checkInTicket(ticket) {
|
||||
async function checkIn(attendee, count = 1) {
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.tickets.checkIn(ticket.id)
|
||||
if (result.ticket) {
|
||||
await db.tickets.put(result.ticket)
|
||||
search = ''
|
||||
const result = await api.attendees.checkIn(attendee.id, { count })
|
||||
if (result.attendee) {
|
||||
await db.attendees.put(result.attendee)
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInWithVolunteer(attendee) {
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true })
|
||||
if (result.attendee) await db.attendees.put(result.attendee)
|
||||
if (result.volunteer) await db.volunteers.put(result.volunteer)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function remaining(a) {
|
||||
return (a.party_size ?? 1) - (a.checked_in_count ?? 0)
|
||||
}
|
||||
|
||||
function progressLabel(a) {
|
||||
const ps = a.party_size ?? 1
|
||||
const ci = a.checked_in_count ?? 0
|
||||
if (ps <= 1) return null
|
||||
return `${ci}/${ps} checked in`
|
||||
}
|
||||
|
||||
function fmt(ts) {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
|
|
@ -189,9 +155,6 @@
|
|||
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => { showAll = !showAll; search = ''; manuallySelectedId = null }}>
|
||||
{showAll ? '✕ Close' : '☰ Browse'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if scanning}
|
||||
|
|
@ -209,122 +172,74 @@
|
|||
<div class="gate-msg gate-msg-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Exact code/ID match card -->
|
||||
{#if matchedTicket}
|
||||
{@const p = participantFor(matchedTicket)}
|
||||
<!-- Matched attendee card -->
|
||||
{#if selected}
|
||||
{@const rem = remaining(selected)}
|
||||
{@const prog = progressLabel(selected)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name">{nameFor(matchedTicket)}</div>
|
||||
{#if matchedTicket.ticket_type}
|
||||
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
||||
<div class="gate-match-name">{selected.name}</div>
|
||||
{#if selected.ticket_type}
|
||||
<div class="gate-match-sub">{selected.ticket_type}</div>
|
||||
{/if}
|
||||
{#if matchedTicket.external_id}
|
||||
<div class="gate-match-sub text-muted">#{matchedTicket.external_id}</div>
|
||||
{#if selected.ticket_id}
|
||||
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
|
||||
{/if}
|
||||
{#if p?.email}
|
||||
<div class="gate-match-sub text-muted">{p.email}</div>
|
||||
{#if prog}
|
||||
<div class="gate-party">
|
||||
<span class="gate-party-label">{prog}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gate-match-actions">
|
||||
{#if !matchedTicket.checked_in_at}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkInTicket(matchedTicket)}>
|
||||
✓ Check in
|
||||
{#if rem > 0}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
|
||||
✓ Check in 1
|
||||
</button>
|
||||
{#if rem > 1}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
|
||||
Check in all {rem}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="gate-done">✓ Checked in {fmt(matchedTicket.checked_in_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="gate-done">All checked in</span>
|
||||
{/if}
|
||||
|
||||
<!-- Single participant match — show their tickets -->
|
||||
{:else if selectedParticipant}
|
||||
{@const pts = ticketsFor(selectedParticipant.id)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name-row">
|
||||
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
||||
{#if manuallySelectedId}
|
||||
<button class="gbtn gbtn-ghost" style="font-size:0.8rem;padding:0.3rem 0.6rem"
|
||||
onclick={() => manuallySelectedId = null}>← Back</button>
|
||||
{#if selected.volunteer_token && !selected.checked_in}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
|
||||
+ Volunteer
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedParticipant.email}
|
||||
<div class="gate-match-sub text-muted">{selectedParticipant.email}</div>
|
||||
{/if}
|
||||
{#if pts.length === 0}
|
||||
<div class="gate-match-sub" style="margin-top:0.5rem;color:var(--c-warn)">No tickets on file</div>
|
||||
{:else}
|
||||
<div style="margin-top:0.75rem;display:flex;flex-direction:column;gap:0.4rem">
|
||||
{#each pts as tk (tk.id)}
|
||||
<div class="gate-ticket-row">
|
||||
<span>
|
||||
<strong>{tk.name || '(unnamed)'}</strong>
|
||||
{#if tk.ticket_type}<span class="text-muted"> · {tk.ticket_type}</span>{/if}
|
||||
</span>
|
||||
{#if tk.checked_in_at}
|
||||
<span class="gate-done" style="font-size:0.8rem">✓ {fmt(tk.checked_in_at)}</span>
|
||||
{:else}
|
||||
<button class="gbtn gbtn-success" style="padding:0.3rem 0.75rem;font-size:0.8rem"
|
||||
onclick={() => checkInTicket(tk)}>✓ Check in</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Multiple participant matches -->
|
||||
{:else if search.trim().length >= 2 && filteredParticipants.length > 1}
|
||||
{:else if search.trim().length >= 2 && filtered.length > 1}
|
||||
<!-- Multiple results list -->
|
||||
<div class="gate-results">
|
||||
{#each filteredParticipants as p}
|
||||
{@const pts = ticketsFor(p.id)}
|
||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||
<button class="gate-result-row" onclick={() => manuallySelectedId = p.id}>
|
||||
{#each filtered as a}
|
||||
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
|
||||
<span>
|
||||
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
||||
{#if p.email && p.preferred_name}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
|
||||
{/if}
|
||||
<strong>{a.name}</strong>
|
||||
{#if a.ticket_type} · {a.ticket_type}{/if}
|
||||
</span>
|
||||
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if search.trim().length >= 2}
|
||||
<div class="gate-msg gate-msg-warn">No matching participants or tickets found.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Browse all participants -->
|
||||
{#if showAll && !matchedTicket && !selectedParticipant && search.trim().length < 2}
|
||||
<div class="gate-results">
|
||||
{#each allParticipantsSorted as p}
|
||||
{@const pts = ticketsFor(p.id)}
|
||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||
<button class="gate-result-row" onclick={() => { manuallySelectedId = p.id; showAll = false }}>
|
||||
<span>
|
||||
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
||||
{#if p.email && p.preferred_name}
|
||||
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'In' : 'Pending'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if search.trim().length >= 2 && filtered.length === 0}
|
||||
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent check-ins -->
|
||||
<div class="gate-recent">
|
||||
<div class="gate-recent-title">Recent Check-ins</div>
|
||||
{#if ($recentCheckIns ?? []).length === 0}
|
||||
<div class="gate-recent-empty">No check-ins yet.</div>
|
||||
<div class="gate-recent-empty">No check-ins yet today.</div>
|
||||
{:else}
|
||||
{#each $recentCheckIns ?? [] as tk}
|
||||
{#each $recentCheckIns ?? [] as a}
|
||||
<div class="gate-recent-row">
|
||||
<span>{nameFor(tk)}</span>
|
||||
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
|
||||
<span>{a.name}</span>
|
||||
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -439,8 +354,7 @@
|
|||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; }
|
||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; }
|
||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
|
||||
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
||||
.gate-party {
|
||||
margin: 0.5rem 0;
|
||||
|
|
@ -470,16 +384,6 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.gate-ticket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--c-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-results {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
<strong style="color:var(--c-text)">Supported formats:</strong><br>
|
||||
<strong>CrowdWork / ticketing platform:</strong> columns <code>Patron Name</code>, <code>Patron Email</code>, <code>Tier Name</code>, <code>Order Number</code><br>
|
||||
<strong>Generic:</strong> columns <code>name</code>, <code>email</code>, <code>ticket_id</code>, <code>ticket_type</code>, <code>note</code><br>
|
||||
Duplicate tickets (same source + external ID) are skipped. Participants are matched or created by email.
|
||||
Duplicate names are skipped.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const token = $derived(window.location.pathname.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
// Token comes from the URL hash: /#/v/TOKEN
|
||||
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
|
||||
let state = $state(null) // { volunteer, shifts, available }
|
||||
let loading = $state(true)
|
||||
|
|
@ -149,7 +150,7 @@
|
|||
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
||||
<div class="kiosk-vol-meta">
|
||||
{state.volunteer.email || ''}
|
||||
{state.volunteer.is_lead ? ' · Co-Lead' : ''}
|
||||
{state.volunteer.is_lead ? ' · Department Lead' : ''}
|
||||
</div>
|
||||
<div class="kiosk-token">Token: <code>{token}</code></div>
|
||||
</div>
|
||||
|
|
@ -1,32 +1,20 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
import { saveSession } from '../db.js'
|
||||
|
||||
let { onlogin, error: externalError = '' } = $props()
|
||||
let { onlogin } = $props()
|
||||
|
||||
let email = $state('')
|
||||
let username = $state('')
|
||||
let password = $state('')
|
||||
let error = $state('')
|
||||
|
||||
$effect(() => { if (externalError) error = externalError })
|
||||
let loading = $state(false)
|
||||
let ssoEnabled = $state(false)
|
||||
let ssoLoading = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.sso.enabled()
|
||||
ssoEnabled = res.enabled
|
||||
} catch {}
|
||||
})
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
error = ''
|
||||
loading = true
|
||||
try {
|
||||
const { token, user } = await api.login(email, password)
|
||||
const { token, user } = await api.login(username, password)
|
||||
await saveSession(token, user)
|
||||
onlogin({ token, user })
|
||||
} catch (err) {
|
||||
|
|
@ -35,18 +23,6 @@
|
|||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startSSO() {
|
||||
error = ''
|
||||
ssoLoading = true
|
||||
try {
|
||||
const { redirect_url } = await api.sso.init()
|
||||
window.location.href = redirect_url
|
||||
} catch (err) {
|
||||
error = err.message || 'SSO failed'
|
||||
ssoLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
|
@ -58,8 +34,8 @@
|
|||
{/if}
|
||||
<form onsubmit={submit}>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||
<label for="username">Username</label>
|
||||
<input id="username" bind:value={username} autocomplete="username" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
|
@ -69,28 +45,5 @@
|
|||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{#if ssoEnabled}
|
||||
<div class="sso-divider"><span>or</span></div>
|
||||
<button class="btn btn-ghost" style="width:100%" onclick={startSSO} disabled={ssoLoading}>
|
||||
{ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sso-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
gap: 0.75rem;
|
||||
color: var(--c-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.sso-divider::before,
|
||||
.sso-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,528 +0,0 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
let generating = $state(false)
|
||||
let emailing = $state(false)
|
||||
let mergeMode = $state(false)
|
||||
let mergeSource = $state(null)
|
||||
let mergeTarget = $state(null)
|
||||
let expandedId = $state(null)
|
||||
|
||||
// Add participant form
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newName = $state('')
|
||||
let newTicketedName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newPronouns = $state('')
|
||||
let newNote = $state('')
|
||||
|
||||
// Edit participant
|
||||
let editId = $state(null)
|
||||
let editName = $state('')
|
||||
let editTicketedName = $state('')
|
||||
let editEmail = $state('')
|
||||
let editPhone = $state('')
|
||||
let editPronouns = $state('')
|
||||
let editNote = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
// Add ticket form (per participant)
|
||||
let addTicketFor = $state(null) // participant id
|
||||
let addingTicket = $state(false)
|
||||
let newTicketName = $state('')
|
||||
let newTicketType = $state('')
|
||||
let newTicketExtId = $state('')
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin'))
|
||||
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const list = $allParticipants ?? []
|
||||
const s = search.toLowerCase().trim()
|
||||
return list
|
||||
.filter(p => {
|
||||
if (!s) return true
|
||||
return p.preferred_name?.toLowerCase().includes(s) ||
|
||||
p.email?.toLowerCase().includes(s) ||
|
||||
p.phone?.toLowerCase().includes(s)
|
||||
})
|
||||
.sort((a, b) => (a.preferred_name || a.email).localeCompare(b.preferred_name || b.email))
|
||||
})
|
||||
|
||||
function ticketsFor(participantId) {
|
||||
return ($allTickets ?? []).filter(t => t.participant_id === participantId)
|
||||
}
|
||||
|
||||
|
||||
function checkedInCount(participantId) {
|
||||
return ticketsFor(participantId).filter(t => t.checked_in_at).length
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
expandedId = expandedId === id ? null : id
|
||||
}
|
||||
|
||||
async function generateCodes() {
|
||||
generating = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.tickets.generateCodes()
|
||||
success = `Generated ${result.generated} code${result.generated !== 1 ? 's' : ''}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function emailAll() {
|
||||
if (!confirm('Send code emails to all participants with a ticket code and email?')) return
|
||||
emailing = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.tickets.emailAllCodes()
|
||||
success = `Sent ${result.sent} email${result.sent !== 1 ? 's' : ''}${result.skipped ? `, skipped ${result.skipped}` : ''}.`
|
||||
if (result.errors?.length) error = result.errors.join('; ')
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
emailing = false
|
||||
}
|
||||
}
|
||||
|
||||
function startMerge(p) {
|
||||
mergeMode = true
|
||||
mergeSource = p
|
||||
mergeTarget = null
|
||||
error = ''
|
||||
}
|
||||
|
||||
function cancelMerge() {
|
||||
mergeMode = false
|
||||
mergeSource = null
|
||||
mergeTarget = null
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!mergeSource || !mergeTarget) return
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.participants.merge(mergeTarget.id, mergeSource.id)
|
||||
success = `Merged "${mergeSource.preferred_name || mergeSource.email}" into "${mergeTarget.preferred_name || mergeTarget.email}".`
|
||||
if (result.participant) await db.participants.put(result.participant)
|
||||
if (result.tickets?.length) await db.tickets.bulkPut(result.tickets)
|
||||
await db.participants.delete(mergeSource.id)
|
||||
cancelMerge()
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInTicket(tk) {
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.tickets.checkIn(tk.id)
|
||||
if (result.ticket) await db.tickets.put(result.ticket)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function addParticipant(e) {
|
||||
e.preventDefault()
|
||||
adding = true; error = ''
|
||||
try {
|
||||
const p = await api.participants.create({
|
||||
preferred_name: newName, ticket_name: newTicketedName, email: newEmail,
|
||||
phone: newPhone, pronouns: newPronouns, note: newNote,
|
||||
})
|
||||
await db.participants.put(p)
|
||||
showAdd = false
|
||||
newName = newTicketedName = newEmail = newPhone = newPronouns = newNote = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(p) {
|
||||
editId = p.id
|
||||
editName = p.preferred_name
|
||||
editTicketedName = p.ticket_name || ''
|
||||
editEmail = p.email
|
||||
editPhone = p.phone
|
||||
editPronouns = p.pronouns
|
||||
editNote = p.note
|
||||
}
|
||||
|
||||
async function saveEdit(e) {
|
||||
e.preventDefault()
|
||||
saving = true; error = ''
|
||||
try {
|
||||
const p = await api.participants.update(editId, {
|
||||
preferred_name: editName, ticket_name: editTicketedName, email: editEmail,
|
||||
phone: editPhone, pronouns: editPronouns, note: editNote,
|
||||
})
|
||||
await db.participants.put(p)
|
||||
editId = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteParticipant(id) {
|
||||
if (!confirm('Permanently delete this participant and all their records?')) return
|
||||
error = ''
|
||||
try {
|
||||
await api.participants.delete(id)
|
||||
await db.participants.delete(id)
|
||||
editId = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addTicket(e, participantId) {
|
||||
e.preventDefault()
|
||||
addingTicket = true; error = ''
|
||||
try {
|
||||
const tk = await api.tickets.create({
|
||||
participant_id: participantId,
|
||||
name: newTicketName,
|
||||
ticket_type: newTicketType,
|
||||
external_id: newTicketExtId,
|
||||
source: 'manual',
|
||||
})
|
||||
await db.tickets.put(tk)
|
||||
addTicketFor = null
|
||||
newTicketName = newTicketType = newTicketExtId = ''
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
addingTicket = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Participants</h1>
|
||||
<div class="actions">
|
||||
{#if canManage}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add</button>
|
||||
<a href="/api/participants/export" class="btn btn-ghost btn-sm">Export CSV</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={generateCodes} disabled={generating}>
|
||||
{generating ? '…' : '⚿ Generate Codes'}
|
||||
</button>
|
||||
<a href="/api/tickets/export-links" class="btn btn-ghost btn-sm">Export Links</a>
|
||||
<button class="btn btn-ghost btn-sm" onclick={emailAll} disabled={emailing}>
|
||||
{emailing ? '…' : '✉ Email All'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addParticipant}>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="p-name">Preferred Name</label>
|
||||
<input id="p-name" bind:value={newName} placeholder="Preferred name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-tname">Ticketed Name</label>
|
||||
<input id="p-tname" bind:value={newTicketedName} placeholder="Legal/ticketed name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-email">Email</label>
|
||||
<input id="p-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-phone">Phone</label>
|
||||
<input id="p-phone" bind:value={newPhone} placeholder="Optional" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-pronouns">Pronouns</label>
|
||||
<input id="p-pronouns" bind:value={newPronouns} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="p-note">Note</label>
|
||||
<input id="p-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding || (!newName && !newEmail)}>
|
||||
{adding ? 'Adding…' : 'Add participant'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mergeMode && mergeSource}
|
||||
<div class="card" style="margin-bottom:1.5rem;border-color:var(--c-accent)">
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<strong>Merge:</strong> "{mergeSource.preferred_name || mergeSource.email}" will be merged into the participant you select below.
|
||||
All their tickets and volunteer records will move to the target.
|
||||
</div>
|
||||
{#if mergeTarget}
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<strong>Target:</strong> {mergeTarget.preferred_name || mergeTarget.email} ({mergeTarget.email})
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick={confirmMerge}>Confirm merge</button>
|
||||
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted" style="font-size:0.875rem">Click a participant row below to select as merge target.</div>
|
||||
<div class="actions" style="margin-top:0.5rem">
|
||||
<button class="btn btn-ghost" onclick={cancelMerge}>Cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="alert alert-success">{success}</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input placeholder="Search name or email…" bind:value={search} />
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($allParticipants ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No participants yet</strong>
|
||||
<p>Import a CSV or wait for volunteer signups.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preferred Name</th>
|
||||
<th>Email</th>
|
||||
<th>Tickets</th>
|
||||
<th>Status</th>
|
||||
{#if canManage}<th></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as p (p.id)}
|
||||
{@const pts = ticketsFor(p.id)}
|
||||
{@const ci = checkedInCount(p.id)}
|
||||
{@const isExpanded = expandedId === p.id}
|
||||
{@const isMergeTarget = mergeMode && mergeSource?.id !== p.id}
|
||||
{@const isEditing = editId === p.id}
|
||||
{#if isEditing}
|
||||
<tr class="edit-row">
|
||||
<td colspan={canManage ? 5 : 4}>
|
||||
<form class="participant-edit-form" onsubmit={saveEdit}>
|
||||
<div class="edit-fields">
|
||||
<input bind:value={editName} placeholder="Preferred name" />
|
||||
<input bind:value={editTicketedName} placeholder="Ticketed name" />
|
||||
<input type="email" bind:value={editEmail} placeholder="Email" />
|
||||
<input bind:value={editPhone} placeholder="Phone" />
|
||||
<input bind:value={editPronouns} placeholder="Pronouns" />
|
||||
<input bind:value={editNote} placeholder="Note" style="flex:2" />
|
||||
</div>
|
||||
<div class="actions" style="margin-top:0.5rem">
|
||||
<button type="submit" class="btn btn-primary btn-sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => editId = null}>Cancel</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick={() => deleteParticipant(editId)}>Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr
|
||||
class:merge-target={isMergeTarget}
|
||||
onclick={mergeMode && mergeSource?.id !== p.id ? () => { mergeTarget = p } : null}
|
||||
style={mergeMode && mergeSource?.id !== p.id ? 'cursor:pointer' : ''}
|
||||
>
|
||||
<td class="td-name">
|
||||
<strong>{p.preferred_name || '—'}</strong>
|
||||
{#if p.pronouns}
|
||||
<span class="text-muted" style="font-size:0.78rem"> · {p.pronouns}</span>
|
||||
{/if}
|
||||
{#if p.ticket_name && p.ticket_name !== p.preferred_name}
|
||||
<div class="text-muted" style="font-size:0.78rem">Ticket: {p.ticket_name}</div>
|
||||
{/if}
|
||||
{#if p.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{p.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
{p.email || '—'}
|
||||
{#if p.phone}
|
||||
<div style="font-size:0.78rem">{p.phone}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if pts.length > 0}
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); toggleExpand(p.id) }}>
|
||||
{pts.length} ticket{pts.length !== 1 ? 's' : ''}
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if pts.length > 0}
|
||||
<span class="badge {ci === pts.length ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
||||
{ci}/{pts.length} in
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge badge-unchecked">No ticket</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
<td class="td-actions">
|
||||
{#if !mergeMode}
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startEdit(p) }}
|
||||
title="Edit participant">✎</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={(e) => { e.stopPropagation(); startMerge(p) }}
|
||||
title="Merge this participant into another">⇄</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{#if isExpanded && !isEditing}
|
||||
<tr class="ticket-rows">
|
||||
<td colspan="5">
|
||||
<div class="ticket-list">
|
||||
{#each pts as tk (tk.id)}
|
||||
<div class="ticket-row">
|
||||
<div>
|
||||
<strong>{tk.name || '(unnamed)'}</strong>
|
||||
{#if tk.ticket_type}
|
||||
<span class="text-muted"> · {tk.ticket_type}</span>
|
||||
{/if}
|
||||
{#if tk.external_id}
|
||||
<span class="text-muted" style="font-size:0.78rem"> · #{tk.external_id}</span>
|
||||
{/if}
|
||||
{#if tk.code}
|
||||
<div style="font-size:0.75rem;margin-top:0.15rem">
|
||||
<code style="color:var(--c-accent-h)">{tk.code}</code>
|
||||
{#if p.email && canManage}
|
||||
<button class="btn btn-ghost btn-sm" style="padding:0.1rem 0.4rem;margin-left:0.25rem"
|
||||
onclick={() => api.tickets.emailCode(tk.id).then(() => success = 'Email sent.').catch(e => error = e.message)}>✉</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
{#if tk.checked_in_at}
|
||||
<span class="badge badge-checked">Checked in {fmtTime(tk.checked_in_at)}</span>
|
||||
{:else}
|
||||
<button class="btn btn-success btn-sm" onclick={() => checkInTicket(tk)}>✓ Check in</button>
|
||||
{/if}
|
||||
<div class="text-muted" style="font-size:0.75rem">{tk.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if canManage}
|
||||
{#if addTicketFor === p.id}
|
||||
<form class="ticket-add-form" onsubmit={(e) => addTicket(e, p.id)}>
|
||||
<input bind:value={newTicketName} placeholder="Name on ticket (optional)" style="flex:2" />
|
||||
<input bind:value={newTicketType} placeholder="Type (optional)" style="flex:1" />
|
||||
<input bind:value={newTicketExtId} placeholder="External ID (optional)" style="flex:1" />
|
||||
<button type="submit" class="btn btn-primary btn-sm" disabled={addingTicket}>
|
||||
{addingTicket ? '…' : 'Add'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={() => addTicketFor = null}>Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button class="btn btn-ghost btn-sm" style="align-self:flex-start;margin-top:0.25rem"
|
||||
onclick={() => { addTicketFor = p.id; newTicketName = newTicketType = newTicketExtId = '' }}>
|
||||
+ Add ticket
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.merge-target:hover { background: rgba(var(--c-accent-rgb, 99,102,241), 0.08); }
|
||||
.ticket-rows td { padding: 0; background: var(--c-bg); }
|
||||
.ticket-list { padding: 0.5rem 1rem 0.75rem; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.ticket-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--c-surface);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ticket-add-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--c-border);
|
||||
}
|
||||
.ticket-add-form input {
|
||||
min-width: 0;
|
||||
font-size: 0.825rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
.edit-row td { padding: 0.5rem 1rem; background: var(--c-bg); }
|
||||
.participant-edit-form { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.edit-fields { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.edit-fields input { flex: 1; min-width: 120px; font-size: 0.825rem; padding: 0.3rem 0.5rem; width: auto; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.td-name { width: 100%; }
|
||||
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||
.ticket-rows { padding: 0; border: none; border-radius: 0; margin-top: -0.5rem; }
|
||||
.ticket-rows td { width: 100%; }
|
||||
.edit-row { padding: 0.75rem; }
|
||||
.edit-row td { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,24 +10,12 @@
|
|||
let editShift = $state({})
|
||||
let saving = $state(false)
|
||||
|
||||
// Add shift form
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newDeptID = $state('')
|
||||
let newName = $state('')
|
||||
let newDay = $state('')
|
||||
let newStart = $state('')
|
||||
let newEnd = $state('')
|
||||
let newCapacity = $state(0)
|
||||
|
||||
// For volunteer assignment dropdown
|
||||
let assigningShiftID = $state(null)
|
||||
let assignVolID = $state(0)
|
||||
let assigning = $state(false)
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
|
|
@ -55,7 +43,7 @@
|
|||
// Departments visible to this user
|
||||
const visibleDepts = $derived.by(() => {
|
||||
const depts = $allDepts ?? []
|
||||
if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
return depts
|
||||
})
|
||||
|
||||
|
|
@ -135,13 +123,11 @@
|
|||
|
||||
try {
|
||||
const res = await api.shifts.reorder(positions)
|
||||
if (res && !res.ok) throw new Error('Reorder failed')
|
||||
await db.transaction('rw', db.shifts, async () => {
|
||||
if (res && !res.ok) throw new Error()
|
||||
for (const p of positions) {
|
||||
const s = await db.shifts.get(p.id)
|
||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
|
|
@ -208,48 +194,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function addShift(e) {
|
||||
e.preventDefault()
|
||||
if (!newDeptID) return
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const s = await api.shifts.create({
|
||||
department_id: parseInt(newDeptID),
|
||||
name: newName,
|
||||
day: newDay,
|
||||
start_time: newStart,
|
||||
end_time: newEnd,
|
||||
capacity: parseInt(newCapacity) || 0,
|
||||
})
|
||||
await db.shifts.put(s)
|
||||
showAdd = false
|
||||
newName = newDay = newStart = newEnd = ''
|
||||
newDeptID = ''
|
||||
newCapacity = 0
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShift(s) {
|
||||
if (!confirm(`Delete shift "${s.name}"?`)) return
|
||||
try {
|
||||
await api.shifts.delete(s.id)
|
||||
await db.shifts.delete(s.id)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function formatDay(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d + 'T00:00:00')
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function fmt(t) {
|
||||
if (!t) return ''
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
|
|
@ -260,66 +204,17 @@
|
|||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Schedule</h1>
|
||||
{#if canManage}
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</button>
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="page-title">Schedule Board</h1>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addShift}>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="s-dept">Department *</label>
|
||||
<select id="s-dept" bind:value={newDeptID} required>
|
||||
<option value="">Select department…</option>
|
||||
{#each visibleDepts as d}
|
||||
<option value={String(d.id)}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-name">Shift name *</label>
|
||||
<input id="s-name" bind:value={newName} required placeholder="e.g. Gate Morning" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-day">Day *</label>
|
||||
<input id="s-day" type="date" bind:value={newDay} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-cap">Capacity <span class="text-muted">(0 = unlimited)</span></label>
|
||||
<input id="s-cap" type="number" min="0" bind:value={newCapacity} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-start">Start time *</label>
|
||||
<input id="s-start" type="time" bind:value={newStart} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-end">End time *</label>
|
||||
<input id="s-end" type="time" bind:value={newEnd} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add shift'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($allShifts ?? []).length === 0 && !showAdd}
|
||||
{#if ($allShifts ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No shifts scheduled yet</strong>
|
||||
<p>Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.</p>
|
||||
<strong>No shifts yet</strong>
|
||||
<p>Create shifts in the Shifts page first.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each board as { dept, days }}
|
||||
|
|
@ -331,7 +226,7 @@
|
|||
</div>
|
||||
|
||||
{#each days as [day, rows]}
|
||||
<div class="board-day-label">{formatDay(day)}</div>
|
||||
<div class="board-day-label">{day}</div>
|
||||
|
||||
{#each rows as { shift, assigned, hasConflict }, i}
|
||||
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
||||
|
|
@ -380,20 +275,17 @@
|
|||
<span class="board-cap">{assigned.length}</span>
|
||||
{/if}
|
||||
{#if hasConflict}
|
||||
<span class="badge badge-lead">⚠ conflict</span>
|
||||
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="board-shift-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Move up"
|
||||
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Assigned volunteers -->
|
||||
|
|
@ -402,28 +294,21 @@
|
|||
{#each assigned as { vs, volunteer }}
|
||||
<div class="board-vol-chip">
|
||||
{volunteer.name}
|
||||
{#if volunteer.is_lead}
|
||||
<span class="chip-lead">Co-Lead</span>
|
||||
{/if}
|
||||
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||
{/if}
|
||||
{#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
|
||||
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Assign volunteer -->
|
||||
{#if canManage}
|
||||
{#if assigningShiftID === shift.id}
|
||||
<div class="board-assign-row">
|
||||
<select bind:value={assignVolID} style="width:auto">
|
||||
<option value={0}>— Select volunteer —</option>
|
||||
{#each ($allVolunteers ?? [])
|
||||
.filter(v => v.department_id === shift.department_id)
|
||||
.filter(v => !assigned.some(a => a.volunteer.id === v.id))
|
||||
as v}
|
||||
{#each $allVolunteers ?? [] as v}
|
||||
<option value={v.id}>{v.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -439,7 +324,6 @@
|
|||
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
|
@ -527,14 +411,6 @@
|
|||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chip-lead {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
background: rgba(245,158,11,0.2);
|
||||
color: var(--c-warn);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.board-vol-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
import { db } from '../db.js'
|
||||
|
||||
let loading = $state(true)
|
||||
let saving = $state(false)
|
||||
let savingEvent = $state(false)
|
||||
let testing = $state(false)
|
||||
let resetting = $state(false)
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
|
||||
|
|
@ -19,28 +16,8 @@
|
|||
let smtpFromName = $state('')
|
||||
let baseURL = $state('')
|
||||
let testEmail = $state('')
|
||||
let noteLabel = $state('Additional note')
|
||||
let noteRequired = $state(false)
|
||||
let eventName = $state('')
|
||||
let eventVenue = $state('')
|
||||
let eventStartDate = $state('')
|
||||
let eventEndDate = $state('')
|
||||
let eventTimezone = $state('')
|
||||
const timezones = Intl.supportedValuesOf('timeZone')
|
||||
let discourseSSOUrl = $state('')
|
||||
let discourseSSOSecret = $state('')
|
||||
let shiftSignupsOpen = $state(false)
|
||||
let togglingSignups = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const ev = await api.event.get()
|
||||
eventName = ev.name ?? ''
|
||||
eventVenue = ev.venue ?? ''
|
||||
eventStartDate = ev.start_date ?? ''
|
||||
eventEndDate = ev.end_date ?? ''
|
||||
eventTimezone = ev.timezone ?? ''
|
||||
} catch {}
|
||||
try {
|
||||
const s = await api.settings.get()
|
||||
smtpHost = s.smtp_host ?? ''
|
||||
|
|
@ -50,11 +27,6 @@
|
|||
smtpFrom = s.smtp_from ?? ''
|
||||
smtpFromName = s.smtp_from_name ?? ''
|
||||
baseURL = s.base_url ?? ''
|
||||
noteLabel = s.volunteer_note_label ?? 'Additional note'
|
||||
noteRequired = s.volunteer_note_required ?? false
|
||||
discourseSSOUrl = s.discourse_sso_url ?? ''
|
||||
discourseSSOSecret = ''
|
||||
shiftSignupsOpen = s.shift_signups_open ?? false
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
|
|
@ -62,28 +34,6 @@
|
|||
}
|
||||
})
|
||||
|
||||
async function saveEvent(e) {
|
||||
e.preventDefault()
|
||||
savingEvent = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const updated = await api.event.update({
|
||||
name: eventName,
|
||||
venue: eventVenue,
|
||||
start_date: eventStartDate,
|
||||
end_date: eventEndDate,
|
||||
timezone: eventTimezone,
|
||||
})
|
||||
await db.event.put({ ...updated, id: 1 })
|
||||
success = 'Event saved.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
savingEvent = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
saving = true
|
||||
|
|
@ -94,17 +44,12 @@
|
|||
smtp_host: smtpHost,
|
||||
smtp_port: smtpPort,
|
||||
smtp_user: smtpUser,
|
||||
smtp_password: smtpPassword,
|
||||
smtp_password: smtpPassword, // empty = keep existing
|
||||
smtp_from: smtpFrom,
|
||||
smtp_from_name: smtpFromName,
|
||||
base_url: baseURL,
|
||||
volunteer_note_label: noteLabel,
|
||||
volunteer_note_required: noteRequired,
|
||||
discourse_sso_url: discourseSSOUrl,
|
||||
discourse_sso_secret: discourseSSOSecret,
|
||||
})
|
||||
smtpPassword = ''
|
||||
discourseSSOSecret = ''
|
||||
success = 'Settings saved.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
|
|
@ -113,39 +58,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function toggleSignups() {
|
||||
const opening = !shiftSignupsOpen
|
||||
if (opening && !confirm('This will email all confirmed volunteers their shift signup links. Continue?')) return
|
||||
togglingSignups = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.settings.toggleShiftSignups(opening)
|
||||
shiftSignupsOpen = result.shift_signups_open
|
||||
success = opening ? 'Shift signups opened. Emails are being sent.' : 'Shift signups closed.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
togglingSignups = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModel(label, fn) {
|
||||
if (resetting) return
|
||||
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
|
||||
resetting = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await fn()
|
||||
success = `Deleted ${result.deleted} ${label}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
resetting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
if (!testEmail) return
|
||||
testing = true
|
||||
|
|
@ -177,50 +89,12 @@
|
|||
{#if loading}
|
||||
<div class="text-muted">Loading…</div>
|
||||
{:else}
|
||||
<form onsubmit={saveEvent}>
|
||||
<div class="card">
|
||||
<h2 class="card-title">Event</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label for="e-name">Event Name *</label>
|
||||
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label for="e-venue">Venue</label>
|
||||
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="e-start">Start Date *</label>
|
||||
<input id="e-start" type="date" bind:value={eventStartDate} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="e-end">End Date *</label>
|
||||
<input id="e-end" type="date" bind:value={eventEndDate} required />
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label for="e-tz">Timezone</label>
|
||||
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
|
||||
<datalist id="tz-list">
|
||||
{#each timezones as tz}
|
||||
<option value={tz} />
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={savingEvent}>
|
||||
{savingEvent ? 'Saving…' : 'Save Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onsubmit={save}>
|
||||
<div class="card">
|
||||
<h2 class="card-title">SMTP Email</h2>
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group" style="grid-column:1">
|
||||
<label for="s-host">SMTP Host</label>
|
||||
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
||||
</div>
|
||||
|
|
@ -248,27 +122,10 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
|
||||
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label>
|
||||
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||
</div>
|
||||
|
||||
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
|
||||
<p class="card-hint" style="margin-bottom:1rem">
|
||||
Enable DiscourseConnect SSO so users can log in with their Discourse account.
|
||||
Set the same secret in your Discourse admin under Connect > discourse connect secret.
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label for="sso-url">Discourse URL</label>
|
||||
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label for="sso-secret">SSO Secret</label>
|
||||
<input id="sso-secret" type="password" bind:value={discourseSSOSecret}
|
||||
placeholder="Leave blank to keep existing" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
|
|
@ -279,7 +136,7 @@
|
|||
|
||||
<!-- Test email -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">Test Email</h2>
|
||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
||||
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
<label for="s-test">Send to</label>
|
||||
|
|
@ -290,69 +147,5 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volunteer Signup -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">Volunteer Signup</h2>
|
||||
<div class="form-group">
|
||||
<label for="s-note-label">Note Field Label</label>
|
||||
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={noteRequired} />
|
||||
Note field is required
|
||||
</label>
|
||||
<p class="card-hint" style="margin-top:0.75rem">
|
||||
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shift Signups -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">Shift Signups</h2>
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<span style="font-size:0.875rem">
|
||||
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
|
||||
</span>
|
||||
<button class="btn {shiftSignupsOpen ? 'btn-ghost' : 'btn-primary'}"
|
||||
onclick={toggleSignups} disabled={togglingSignups}>
|
||||
{#if togglingSignups}
|
||||
Working…
|
||||
{:else}
|
||||
{shiftSignupsOpen ? 'Close Signups' : 'Open Signups'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if !shiftSignupsOpen}
|
||||
<p class="card-hint" style="margin-top:0.75rem">
|
||||
Opening signups will email all confirmed volunteers their shift signup links.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="card">
|
||||
<h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
|
||||
<p class="card-hint" style="margin-bottom:1rem">
|
||||
Permanently delete all records of a given type. This cannot be undone.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
||||
Delete All Tickets
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
||||
Delete All Volunteers
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
||||
Delete All Shifts
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
||||
Delete All Departments
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
||||
Delete All Shift Assignments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
221
frontend/src/pages/Shifts.svelte
Normal file
221
frontend/src/pages/Shifts.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
<script>
|
||||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
let { session } = $props()
|
||||
|
||||
let error = $state('')
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newDeptID = $state('')
|
||||
let newName = $state('')
|
||||
let newDay = $state('')
|
||||
let newStart = $state('')
|
||||
let newEnd = $state('')
|
||||
let newCapacity = $state(0)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||
|
||||
const allShifts = liveQuery(() =>
|
||||
db.shifts.filter(s => !s.deleted_at).toArray()
|
||||
)
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
)
|
||||
|
||||
// Group shifts by department, then by day
|
||||
const grouped = $derived.by(() => {
|
||||
const shifts = $allShifts ?? []
|
||||
const depts = $allDepts ?? []
|
||||
|
||||
const byDept = {}
|
||||
for (const s of shifts) {
|
||||
if (!byDept[s.department_id]) byDept[s.department_id] = {}
|
||||
if (!byDept[s.department_id][s.day]) byDept[s.department_id][s.day] = []
|
||||
byDept[s.department_id][s.day].push(s)
|
||||
}
|
||||
|
||||
return depts
|
||||
.filter(d => byDept[d.id])
|
||||
.map(d => ({
|
||||
dept: d,
|
||||
days: Object.entries(byDept[d.id] || {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, dayShifts]) => ({
|
||||
day,
|
||||
shifts: [...dayShifts].sort((a, b) => a.start_time.localeCompare(b.start_time)),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
// Shifts not yet in any department group (e.g. orphaned)
|
||||
const ungrouped = $derived.by(() => {
|
||||
const shifts = $allShifts ?? []
|
||||
const deptIDs = new Set(($allDepts ?? []).map(d => d.id))
|
||||
return shifts.filter(s => !deptIDs.has(s.department_id))
|
||||
})
|
||||
|
||||
async function addShift(e) {
|
||||
e.preventDefault()
|
||||
if (!newDeptID) return
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const s = await api.shifts.create({
|
||||
department_id: parseInt(newDeptID),
|
||||
name: newName,
|
||||
day: newDay,
|
||||
start_time: newStart,
|
||||
end_time: newEnd,
|
||||
capacity: parseInt(newCapacity) || 0,
|
||||
})
|
||||
await db.shifts.put(s)
|
||||
showAdd = false
|
||||
newName = newDay = newStart = newEnd = ''
|
||||
newDeptID = ''
|
||||
newCapacity = 0
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShift(s) {
|
||||
if (!confirm(`Delete shift "${s.name}"?`)) return
|
||||
try {
|
||||
await api.shifts.delete(s.id)
|
||||
await db.shifts.delete(s.id)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
// t is HH:MM, format nicely
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'pm' : 'am'
|
||||
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
|
||||
}
|
||||
|
||||
function formatDay(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d + 'T00:00:00')
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Shifts</h1>
|
||||
{#if canManage}
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addShift}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="s-dept">Department *</label>
|
||||
<select id="s-dept" bind:value={newDeptID} required>
|
||||
<option value="">Select department…</option>
|
||||
{#each $allDepts ?? [] as d}
|
||||
<option value={String(d.id)}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-name">Shift name *</label>
|
||||
<input id="s-name" bind:value={newName} required placeholder="e.g. Gate Morning" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-day">Day *</label>
|
||||
<input id="s-day" type="date" bind:value={newDay} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-cap">Capacity <span class="text-muted">(0 = unlimited)</span></label>
|
||||
<input id="s-cap" type="number" min="0" bind:value={newCapacity} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-start">Start time *</label>
|
||||
<input id="s-start" type="time" bind:value={newStart} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-end">End time *</label>
|
||||
<input id="s-end" type="time" bind:value={newEnd} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add shift'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($allShifts ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No shifts yet</strong>
|
||||
<p>Add shifts to schedule your volunteers.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as { dept, days }}
|
||||
<div style="margin-bottom:2rem">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<span class="dept-dot" style="background:{dept.color}"></span>
|
||||
<strong style="font-size:1rem">{dept.name}</strong>
|
||||
</div>
|
||||
{#each days as { day, shifts }}
|
||||
<div style="margin-bottom:1rem">
|
||||
<div class="text-muted" style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem;padding-left:1rem">
|
||||
{formatDay(day)}
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tbody>
|
||||
{#each shifts as s (s.id)}
|
||||
<tr>
|
||||
<td><strong>{s.name}</strong></td>
|
||||
<td class="text-muted">{formatTime(s.start_time)} – {formatTime(s.end_time)}</td>
|
||||
<td class="text-muted">
|
||||
{#if s.capacity}
|
||||
Capacity: {s.capacity}
|
||||
{:else}
|
||||
Unlimited
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
<td style="text-align:right">
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(s)}>Delete</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{#if ungrouped.length > 0}
|
||||
<div class="text-muted" style="font-size:0.85rem">
|
||||
{ungrouped.length} shift(s) with unknown departments
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -12,14 +12,13 @@
|
|||
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newEmail = $state('')
|
||||
let newName = $state('')
|
||||
let newUsername = $state('')
|
||||
let newPassword = $state('')
|
||||
let newRoles = $state([])
|
||||
let newRole = $state('gate')
|
||||
let newDeptIDs = $state([])
|
||||
|
||||
let editID = $state(null)
|
||||
let editRoles = $state([])
|
||||
let editRole = $state('')
|
||||
let editDeptIDs = $state([])
|
||||
let editPassword = $state('')
|
||||
let saving = $state(false)
|
||||
|
|
@ -29,7 +28,7 @@
|
|||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
)
|
||||
|
||||
const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper']
|
||||
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
|
||||
|
||||
const me = $derived(session?.user?.id)
|
||||
|
||||
|
|
@ -52,16 +51,15 @@
|
|||
error = ''
|
||||
try {
|
||||
const u = await api.users.create({
|
||||
email: newEmail,
|
||||
preferred_name: newName,
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
roles: newRoles,
|
||||
role: newRole,
|
||||
department_ids: newDeptIDs,
|
||||
})
|
||||
users = [...users, u]
|
||||
showAdd = false
|
||||
newEmail = newName = newPassword = ''
|
||||
newRoles = []
|
||||
newUsername = newPassword = ''
|
||||
newRole = 'gate'
|
||||
newDeptIDs = []
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
|
|
@ -72,7 +70,7 @@
|
|||
|
||||
function startEdit(u) {
|
||||
editID = u.id
|
||||
editRoles = [...(u.roles || [])]
|
||||
editRole = u.role
|
||||
editDeptIDs = [...(u.department_ids || [])]
|
||||
editPassword = ''
|
||||
}
|
||||
|
|
@ -85,7 +83,7 @@
|
|||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const payload = { roles: editRoles, department_ids: editDeptIDs }
|
||||
const payload = { role: editRole, department_ids: editDeptIDs }
|
||||
if (editPassword) payload.password = editPassword
|
||||
const updated = await api.users.update(u.id, payload)
|
||||
users = users.map(x => x.id === u.id ? updated : x)
|
||||
|
|
@ -98,7 +96,7 @@
|
|||
}
|
||||
|
||||
async function deleteUser(u) {
|
||||
if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return
|
||||
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await api.users.delete(u.id)
|
||||
users = users.filter(x => x.id !== u.id)
|
||||
|
|
@ -107,7 +105,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleItem(id, list) {
|
||||
function toggleDept(id, list) {
|
||||
const idx = list.indexOf(id)
|
||||
if (idx === -1) return [...list, id]
|
||||
return list.filter(x => x !== id)
|
||||
|
|
@ -119,7 +117,7 @@
|
|||
}
|
||||
|
||||
function roleLabel(r) {
|
||||
return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
||||
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -131,14 +129,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
|
||||
<strong style="color:var(--c-text)">Roles:</strong>
|
||||
admin — full access ·
|
||||
staffing — volunteers, shifts, departments ·
|
||||
colead — manage assigned departments only ·
|
||||
gatekeeper — check-in only
|
||||
</p>
|
||||
|
||||
{#if loadError}
|
||||
<div class="alert alert-error">{loadError}</div>
|
||||
{/if}
|
||||
|
|
@ -149,31 +139,22 @@
|
|||
{#if showAdd}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addUser}>
|
||||
<div class="form-grid-3">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="u-email">Email *</label>
|
||||
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-name">Preferred Name</label>
|
||||
<input id="u-name" bind:value={newName} placeholder="Preferred name" autocomplete="off" />
|
||||
<label for="u-username">Username *</label>
|
||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-password">Password *</label>
|
||||
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||
{#each availableRoles as r}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
checked={newRoles.includes(r)}
|
||||
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
<label for="u-role">Role *</label>
|
||||
<select id="u-role" bind:value={newRole}>
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -181,10 +162,10 @@
|
|||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||
{#each $allDepts ?? [] as d}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={newDeptIDs.includes(d.id)}
|
||||
onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
|
||||
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
|
||||
<span class="dept-dot" style="background:{d.color}"></span>
|
||||
{d.name}
|
||||
</label>
|
||||
|
|
@ -206,16 +187,15 @@
|
|||
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="empty">
|
||||
<strong>No additional users</strong>
|
||||
<p>The admin account was created at setup. Add users above to delegate access.</p>
|
||||
<strong>No users yet</strong>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preferred Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Departments</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -223,28 +203,23 @@
|
|||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
{#if editID === u.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name"><strong>{u.preferred_name || u.email}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<tr>
|
||||
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<td>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each availableRoles as r}
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox"
|
||||
checked={editRoles.includes(r)}
|
||||
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
<select bind:value={editRole} style="width:auto;margin:0">
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
{/each}
|
||||
</div>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each $allDepts ?? [] as d}
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox"
|
||||
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
checked={editDeptIDs.includes(d.id)}
|
||||
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
||||
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
||||
{d.name}
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -254,7 +229,7 @@
|
|||
placeholder="New password (leave blank to keep)"
|
||||
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
|
|
@ -265,20 +240,19 @@
|
|||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="td-name">
|
||||
<strong>{u.preferred_name || u.email}</strong>
|
||||
<td>
|
||||
<strong>{u.username}</strong>
|
||||
{#if u.id === me}
|
||||
<span class="badge badge-role">you</span>
|
||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||
{/if}
|
||||
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
|
||||
</td>
|
||||
<td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
|
||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||
<td class="td-actions">
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||
{#if u.id !== me}
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Remove</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -290,11 +264,3 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.td-name { width: 100%; }
|
||||
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,241 +0,0 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
|
||||
let loading = $state(true)
|
||||
let submitting = $state(false)
|
||||
let error = $state('')
|
||||
let submitted = $state(false)
|
||||
|
||||
let config = $state(null)
|
||||
let preferredName = $state('')
|
||||
let ticketName = $state('')
|
||||
let email = $state('')
|
||||
let pronouns = $state('')
|
||||
let phone = $state('')
|
||||
let departmentId = $state('')
|
||||
let note = $state('')
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
config = await api.signup.config()
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
submitting = true
|
||||
error = ''
|
||||
try {
|
||||
const data = {
|
||||
preferred_name: preferredName.trim(),
|
||||
email: email.trim(),
|
||||
}
|
||||
if (ticketName.trim()) data.ticket_name = ticketName.trim()
|
||||
if (pronouns.trim()) data.pronouns = pronouns.trim()
|
||||
if (phone.trim()) data.phone = phone.trim()
|
||||
if (departmentId) data.department_id = Number(departmentId)
|
||||
if (note.trim()) data.note = note.trim()
|
||||
await api.signup.submit(data)
|
||||
submitted = true
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kiosk">
|
||||
<div class="kiosk-header">
|
||||
<div class="kiosk-brand">Turn<span>pike</span> <span class="kiosk-role">Volunteer Signup</span></div>
|
||||
</div>
|
||||
|
||||
<div class="kiosk-body">
|
||||
{#if loading}
|
||||
<div class="kiosk-center">Loading...</div>
|
||||
{:else if submitted}
|
||||
<div class="kiosk-card" style="text-align:center">
|
||||
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:0.75rem">Thank you!</h2>
|
||||
<p style="color:var(--c-muted);line-height:1.6;margin:0">
|
||||
We've sent a confirmation email to <strong style="color:var(--c-text)">{email}</strong>.
|
||||
Please check your inbox and click the link to confirm your signup.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if config?.event_name && config.event_name !== 'the event'}
|
||||
<h2 class="signup-event-name">{config.event_name}</h2>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="kiosk-alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={submit}>
|
||||
<div class="kiosk-card">
|
||||
<div class="signup-field">
|
||||
<label for="s-name">Preferred Name <span class="req">*</span></label>
|
||||
<input id="s-name" bind:value={preferredName} required placeholder="What should we call you?" />
|
||||
</div>
|
||||
|
||||
<div class="signup-field">
|
||||
<label for="s-ticket">Ticket Name</label>
|
||||
<input id="s-ticket" bind:value={ticketName} placeholder="Name on your ticket (if different)" />
|
||||
</div>
|
||||
|
||||
<div class="signup-field">
|
||||
<label for="s-email">Email <span class="req">*</span></label>
|
||||
<input id="s-email" type="email" bind:value={email} required placeholder="you@example.com" />
|
||||
</div>
|
||||
|
||||
<div class="signup-row">
|
||||
<div class="signup-field">
|
||||
<label for="s-pronouns">Pronouns</label>
|
||||
<input id="s-pronouns" bind:value={pronouns} placeholder="e.g. she/her" />
|
||||
</div>
|
||||
<div class="signup-field">
|
||||
<label for="s-phone">Phone</label>
|
||||
<input id="s-phone" type="tel" bind:value={phone} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if config?.departments?.length > 0}
|
||||
<div class="signup-field">
|
||||
<label for="s-dept">Department Preference</label>
|
||||
<select id="s-dept" bind:value={departmentId}>
|
||||
<option value="">No preference</option>
|
||||
{#each config.departments as dept}
|
||||
<option value={dept.id}>{dept.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="signup-field">
|
||||
<label for="s-note">
|
||||
{config?.volunteer_note_label ?? 'Additional note'}
|
||||
{#if config?.volunteer_note_required}<span class="req">*</span>{/if}
|
||||
</label>
|
||||
<textarea id="s-note" bind:value={note} rows="3"
|
||||
required={config?.volunteer_note_required}
|
||||
placeholder=""></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="kbtn kbtn-primary" style="width:100%;margin-top:0.5rem" disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.kiosk-header {
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.kiosk-brand {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.kiosk-brand span:first-of-type { color: var(--c-accent); }
|
||||
.kiosk-role {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--c-muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.kiosk-body {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.kiosk-center { display: flex; align-items: center; justify-content: center; }
|
||||
.kiosk-alert {
|
||||
background: rgba(239,68,68,0.1);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.kiosk-card {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.signup-event-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--c-text);
|
||||
}
|
||||
.signup-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.signup-field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--c-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.signup-field input,
|
||||
.signup-field select,
|
||||
.signup-field textarea {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 6px;
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.875rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.signup-field input:focus,
|
||||
.signup-field select:focus,
|
||||
.signup-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
.signup-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.req { color: var(--c-accent); }
|
||||
.kbtn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 0.55rem 1rem; border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: background 150ms;
|
||||
}
|
||||
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.kbtn-primary { background: var(--c-accent); color: #fff; }
|
||||
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
|
||||
</style>
|
||||
|
|
@ -8,43 +8,23 @@
|
|||
|
||||
let search = $state('')
|
||||
let filterDept = $state('')
|
||||
let filterStatus = $state('')
|
||||
let filterChecked = $state('')
|
||||
let error = $state('')
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newName = $state('')
|
||||
let newTicketName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newDeptID = $state('')
|
||||
let newIsLead = $state(false)
|
||||
let newNote = $state('')
|
||||
|
||||
let editID = $state(null)
|
||||
let editDeptID = $state('')
|
||||
let editIsLead = $state(false)
|
||||
let editNote = $state('')
|
||||
let saving = $state(false)
|
||||
let confirmingID = $state(null)
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const canConfirm = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
let deptInitialized = $state(false)
|
||||
$effect(() => {
|
||||
if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) {
|
||||
filterDept = String(myDeptIDs[0])
|
||||
deptInitialized = true
|
||||
}
|
||||
})
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||
|
||||
const allVolunteers = liveQuery(() =>
|
||||
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||
)
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
|
@ -56,10 +36,8 @@
|
|||
return list
|
||||
.filter(v => {
|
||||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
||||
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
|
||||
if (filterStatus === 'ready' && !v.ready) return false
|
||||
if (filterChecked === 'true' && !v.checked_in) return false
|
||||
if (filterChecked === 'false' && v.checked_in) return false
|
||||
if (s && !v.name.toLowerCase().includes(s) &&
|
||||
!(v.email || '').toLowerCase().includes(s)) return false
|
||||
return true
|
||||
|
|
@ -67,28 +45,15 @@
|
|||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
async function markReady(v) {
|
||||
async function checkIn(v) {
|
||||
try {
|
||||
const updated = await api.volunteers.markReady(v.id)
|
||||
const updated = await api.volunteers.checkIn(v.id)
|
||||
await db.volunteers.put(updated)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmVolunteer(v) {
|
||||
if (confirmingID) return
|
||||
confirmingID = v.id
|
||||
try {
|
||||
const updated = await api.volunteers.confirm(v.id)
|
||||
await db.volunteers.put(updated)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
confirmingID = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addVolunteer(e) {
|
||||
e.preventDefault()
|
||||
adding = true
|
||||
|
|
@ -96,8 +61,8 @@
|
|||
try {
|
||||
const data = {
|
||||
name: newName,
|
||||
ticket_name: newTicketName,
|
||||
email: newEmail,
|
||||
phone: newPhone,
|
||||
is_lead: newIsLead,
|
||||
note: newNote,
|
||||
}
|
||||
|
|
@ -105,7 +70,7 @@
|
|||
const v = await api.volunteers.create(data)
|
||||
await db.volunteers.put(v)
|
||||
showAdd = false
|
||||
newName = newEmail = newTicketName = newNote = ''
|
||||
newName = newEmail = newPhone = newNote = ''
|
||||
newDeptID = ''
|
||||
newIsLead = false
|
||||
} catch (err) {
|
||||
|
|
@ -125,48 +90,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
function startEdit(v) {
|
||||
editID = v.id
|
||||
editDeptID = v.department_id ? String(v.department_id) : ''
|
||||
editIsLead = v.is_lead
|
||||
editNote = v.note ?? ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editID = null
|
||||
}
|
||||
|
||||
async function saveVolunteer(v) {
|
||||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const updated = await api.volunteers.update(v.id, {
|
||||
...v,
|
||||
department_id: editDeptID ? parseInt(editDeptID) : null,
|
||||
is_lead: editIsLead,
|
||||
note: editNote,
|
||||
})
|
||||
await db.volunteers.put(updated)
|
||||
editID = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function deptFor(id) {
|
||||
return ($allDepts ?? []).find(d => d.id === id)
|
||||
}
|
||||
|
||||
function participantHasTickets(participantId) {
|
||||
if (!participantId) return false
|
||||
return ($allTickets ?? []).some(t => t.participant_id === participantId)
|
||||
}
|
||||
|
||||
function participantFor(id) {
|
||||
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
|
@ -186,18 +112,18 @@
|
|||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addVolunteer}>
|
||||
<div class="form-grid">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label for="v-name">Preferred Name *</label>
|
||||
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
|
||||
<label for="v-name">Name *</label>
|
||||
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-ticket-name">Name on Ticket</label>
|
||||
<input id="v-ticket-name" bind:value={newTicketName} placeholder="Legal/ticketed name" />
|
||||
<label for="v-email">Email</label>
|
||||
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-email">Email *</label>
|
||||
<input id="v-email" type="email" bind:value={newEmail} required placeholder="email@example.com" />
|
||||
<label for="v-phone">Phone</label>
|
||||
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-dept">Department</label>
|
||||
|
|
@ -214,8 +140,8 @@
|
|||
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={newIsLead} />
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
||||
Department lead
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -239,12 +165,10 @@
|
|||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select bind:value={filterStatus} style="width:auto">
|
||||
<option value="">All statuses</option>
|
||||
<option value="unconfirmed">Unregistered</option>
|
||||
<option value="registered">Registered</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ready">Ready</option>
|
||||
<select bind:value={filterChecked} style="width:auto">
|
||||
<option value="">All</option>
|
||||
<option value="false">Not checked in</option>
|
||||
<option value="true">Checked in</option>
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
|
|
@ -254,14 +178,14 @@
|
|||
{#if ($allVolunteers ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No volunteers yet</strong>
|
||||
<p>Add volunteers manually above, or enable public signup in Settings.</p>
|
||||
<p>Add volunteers manually.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preferred Name</th>
|
||||
<th>Name</th>
|
||||
<th>Department</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
|
|
@ -271,112 +195,47 @@
|
|||
<tbody>
|
||||
{#each filtered as v (v.id)}
|
||||
{@const dept = deptFor(v.department_id)}
|
||||
{#if editID === v.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name" style="width:100%">
|
||||
<strong>{v.name}</strong>
|
||||
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if}
|
||||
</td>
|
||||
<td class="td-edit-dept">
|
||||
<select bind:value={editDeptID} style="margin:0">
|
||||
<option value="">No department</option>
|
||||
{#each $allDepts ?? [] as d}
|
||||
<option value={String(d.id)}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="td-edit-checks">
|
||||
<label class="checkbox-label-sm" style="white-space:nowrap">
|
||||
<input type="checkbox" bind:checked={editIsLead} /> Co-Lead
|
||||
</label>
|
||||
</td>
|
||||
<td class="td-edit-note">
|
||||
<input bind:value={editNote} placeholder="Note" style="margin:0" />
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{@const participant = participantFor(v.participant_id)}
|
||||
<tr>
|
||||
<td class="td-name">
|
||||
<td>
|
||||
<strong>{v.name}</strong>
|
||||
{#if v.is_lead}
|
||||
<span class="badge badge-lead">Co-Lead</span>
|
||||
{/if}
|
||||
{#if !v.participant_id}
|
||||
<span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
|
||||
{:else if !participantHasTickets(v.participant_id)}
|
||||
<span class="badge badge-partial" title="No ticket on file">No ticket</span>
|
||||
{/if}
|
||||
{#if participant?.ticket_name && participant.ticket_name !== v.name}
|
||||
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
|
||||
{/if}
|
||||
{#if v.email}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
||||
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
|
||||
{/if}
|
||||
{#if v.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="td-dept text-muted">
|
||||
<td class="text-muted">
|
||||
{#if dept}
|
||||
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="td-status">
|
||||
{#if v.ready}
|
||||
<span class="badge badge-checked">Ready</span>
|
||||
{:else if v.confirmed}
|
||||
<span class="badge badge-confirmed">Confirmed</span>
|
||||
{:else if v.email_confirmed}
|
||||
<span class="badge badge-registered">Registered</span>
|
||||
{:else}
|
||||
<span class="badge badge-unchecked">Unregistered</span>
|
||||
{/if}
|
||||
{#if v.ready_at}
|
||||
<td>
|
||||
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{v.checked_in ? 'Checked in' : 'Pending'}
|
||||
</span>
|
||||
{#if v.checked_in_at}
|
||||
<div class="text-muted" style="font-size:0.75rem">
|
||||
{new Date(v.ready_at).toLocaleTimeString()}
|
||||
{new Date(v.checked_in_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="td-ready">
|
||||
{#if v.confirmed && !v.ready}
|
||||
<CheckInButton onclick={() => markReady(v)} />
|
||||
<td>
|
||||
{#if !v.checked_in}
|
||||
<CheckInButton onclick={() => checkIn(v)} />
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
<td class="td-actions">
|
||||
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.td-name { flex: 1; min-width: 0; order: 1; }
|
||||
.td-ready { flex-shrink: 0; align-self: flex-start; order: 2; }
|
||||
.td-dept { width: 100%; order: 3; }
|
||||
.td-status { width: 100%; order: 4; }
|
||||
.td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
.td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,54 +4,24 @@ import { api } from './api.js'
|
|||
let syncing = false
|
||||
let sseSource = null
|
||||
|
||||
async function checkBuildChanged() {
|
||||
try {
|
||||
const res = await fetch('/api/version')
|
||||
const { build } = await res.json()
|
||||
if (!build) return
|
||||
const stored = await db.meta.get('build')
|
||||
if (!stored || stored.value !== build) {
|
||||
await db.transaction('rw',
|
||||
[db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
await db.meta.clear()
|
||||
await db.event.clear()
|
||||
await db.participants.clear()
|
||||
await db.tickets.clear()
|
||||
await db.departments.clear()
|
||||
await db.volunteers.clear()
|
||||
await db.shifts.clear()
|
||||
await db.volunteer_shifts.clear()
|
||||
await db.meta.put({ key: 'build', value: build })
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function syncPull() {
|
||||
if (syncing) return
|
||||
syncing = true
|
||||
try {
|
||||
await checkBuildChanged()
|
||||
const since = await getLastSync()
|
||||
const data = await api.sync.pull(since)
|
||||
|
||||
await db.transaction('rw',
|
||||
[db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
if (data.event) {
|
||||
await db.event.put(data.event)
|
||||
}
|
||||
if (data.participants?.length) {
|
||||
await db.participants.bulkPut(data.participants)
|
||||
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
|
||||
if (deleted.length) await db.participants.bulkDelete(deleted)
|
||||
}
|
||||
if (data.tickets?.length) {
|
||||
await db.tickets.bulkPut(data.tickets)
|
||||
const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id)
|
||||
if (deleted.length) await db.tickets.bulkDelete(deleted)
|
||||
if (data.attendees?.length) {
|
||||
await db.attendees.bulkPut(data.attendees)
|
||||
// Purge hard-deleted records from Dexie
|
||||
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||
if (deleted.length) await db.attendees.bulkDelete(deleted)
|
||||
}
|
||||
if (data.departments?.length) {
|
||||
await db.departments.bulkPut(data.departments)
|
||||
|
|
@ -77,7 +47,7 @@ export async function syncPull() {
|
|||
}
|
||||
)
|
||||
|
||||
if (data.server_time) await setLastSync(data.server_time)
|
||||
await setLastSync(data.server_time)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Sync pull failed:', err.message)
|
||||
|
|
@ -102,8 +72,8 @@ export function startSSE(onEvent) {
|
|||
try {
|
||||
const payload = JSON.parse(e.data)
|
||||
if (payload.event === 'checkin') {
|
||||
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
|
||||
await db.tickets.put(payload.data.ticket)
|
||||
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||
await db.attendees.put(payload.data.attendee)
|
||||
}
|
||||
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
||||
await db.volunteers.put(payload.data.volunteer)
|
||||
|
|
@ -123,7 +93,7 @@ export function startSSE(onEvent) {
|
|||
syncPull()
|
||||
}, 5000)
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
|
@ -134,23 +104,18 @@ export function stopSSE() {
|
|||
sseSource = null
|
||||
}
|
||||
|
||||
// Poll for sync when online, with exponential backoff on failure
|
||||
let syncInterval = null
|
||||
let onlineHandler = null
|
||||
|
||||
export function startSyncLoop(intervalMs = 30000) {
|
||||
if (syncInterval) return
|
||||
syncInterval = setInterval(() => {
|
||||
if (navigator.onLine) syncPull()
|
||||
}, intervalMs)
|
||||
onlineHandler = () => syncPull()
|
||||
window.addEventListener('online', onlineHandler)
|
||||
window.addEventListener('online', () => syncPull())
|
||||
}
|
||||
|
||||
export function stopSyncLoop() {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
if (onlineHandler) {
|
||||
window.removeEventListener('online', onlineHandler)
|
||||
onlineHandler = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,31 +18,30 @@ function mockFetch(body = {}, status = 200) {
|
|||
}
|
||||
|
||||
describe('syncPull', () => {
|
||||
it('writes participants to Dexie', async () => {
|
||||
it('writes attendees to Dexie', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T12:00:00Z',
|
||||
participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }],
|
||||
tickets: [],
|
||||
attendees: [{ id: 1, name: 'Titania' }],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
volunteer_shifts: [],
|
||||
})
|
||||
// Import fresh to reset syncing guard
|
||||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const p = await db.participants.get(1)
|
||||
expect(p.preferred_name).toBe('Titania')
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a.name).toBe('Titania')
|
||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
||||
})
|
||||
|
||||
it('deletes soft-deleted participants from Dexie', async () => {
|
||||
await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
it('deletes soft-deleted attendees from Dexie', async () => {
|
||||
await db.attendees.put({ id: 1, name: 'Titania' })
|
||||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
tickets: [],
|
||||
attendees: [{ id: 1, name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -51,8 +50,8 @@ describe('syncPull', () => {
|
|||
const { syncPull } = await import('./sync.js')
|
||||
await syncPull()
|
||||
|
||||
const p = await db.participants.get(1)
|
||||
expect(p).toBeUndefined()
|
||||
const a = await db.attendees.get(1)
|
||||
expect(a).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deletes soft-deleted volunteer_shifts from Dexie', async () => {
|
||||
|
|
@ -60,8 +59,7 @@ describe('syncPull', () => {
|
|||
|
||||
mockFetch({
|
||||
server_time: '2026-03-01T13:00:00Z',
|
||||
participants: [],
|
||||
tickets: [],
|
||||
attendees: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
@ -77,8 +75,7 @@ describe('syncPull', () => {
|
|||
it('sets lastSync timestamp', async () => {
|
||||
mockFetch({
|
||||
server_time: '2026-03-02T00:00:00Z',
|
||||
participants: [],
|
||||
tickets: [],
|
||||
attendees: [],
|
||||
departments: [],
|
||||
volunteers: [],
|
||||
shifts: [],
|
||||
|
|
|
|||
167
handle_attendees.go
Normal file
167
handle_attendees.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
types, _ := app.attendeeTicketTypes()
|
||||
total, checkedIn, _ := app.attendeeCounts()
|
||||
writeJSON(w, map[string]any{
|
||||
"attendees": attendees,
|
||||
"ticket_types": types,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createAttendee(a)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := app.updateAttendee(a); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getAttendee(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteAttendee(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCheckInAttendee handles POST /api/attendees/:id/checkin.
|
||||
// Optional body: {"count": N, "also_volunteer": true}
|
||||
// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true
|
||||
// and the attendee has a linked volunteer record.
|
||||
func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
AlsoVolunteer bool `json:"also_volunteer"`
|
||||
}
|
||||
body.Count = 1
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Count < 1 {
|
||||
body.Count = 1
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
a, err := app.checkInAttendee(id, claims.UserID, body.Count)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]any{"attendee": a}
|
||||
|
||||
if body.AlsoVolunteer {
|
||||
v, _ := app.getVolunteerByAttendeeID(id)
|
||||
if v != nil {
|
||||
if !v.CheckedIn {
|
||||
if v2, err := app.checkInVolunteer(v.ID, claims.UserID); err == nil {
|
||||
result["volunteer"] = v2
|
||||
app.broker.publish("checkin", map[string]any{"type": "volunteer", "volunteer": v2})
|
||||
}
|
||||
} else {
|
||||
result["volunteer"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.broker.publish("checkin", map[string]any{"type": "attendee", "attendee": a})
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (app *App) handleExportAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="attendees.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"name", "email", "phone", "ticket_id", "ticket_type", "party_size", "checked_in_count", "note", "checked_in"})
|
||||
for _, a := range attendees {
|
||||
ci := "no"
|
||||
if a.CheckedIn {
|
||||
ci = "yes"
|
||||
}
|
||||
wr.Write([]string{
|
||||
a.Name, a.Email, a.Phone, a.TicketID, a.TicketType,
|
||||
strconv.Itoa(a.PartySize), strconv.Itoa(a.CheckedInCount),
|
||||
a.Note, ci,
|
||||
})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
|
@ -6,14 +6,14 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestParticipantsListCreateDelete(t *testing.T) {
|
||||
func TestAttendeesListCreateDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Create
|
||||
req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token)
|
||||
req := testAuthRequest("POST", "/api/attendees", map[string]string{"name": "Titania"}, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
|
|
@ -23,20 +23,20 @@ func TestParticipantsListCreateDelete(t *testing.T) {
|
|||
id := created["id"].(float64)
|
||||
|
||||
// List
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status = %d", w.Code)
|
||||
}
|
||||
list := parseJSON(t, w)
|
||||
participants := list["participants"].([]any)
|
||||
if len(participants) != 2 { // admin + Titania
|
||||
t.Errorf("list: got %d, want 2", len(participants))
|
||||
attendees := list["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("list: got %d, want 1", len(attendees))
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token)
|
||||
req = testAuthRequest("DELETE", "/api/attendees/"+itoa(int(id)), nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
|
|
@ -44,66 +44,66 @@ func TestParticipantsListCreateDelete(t *testing.T) {
|
|||
}
|
||||
|
||||
// List again — should be empty
|
||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
||||
req = testAuthRequest("GET", "/api/attendees", nil, token)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
list = parseJSON(t, w)
|
||||
if ps, ok := list["participants"].([]any); ok && len(ps) != 1 { // admin remains
|
||||
t.Errorf("after delete: got %d, want 1", len(ps))
|
||||
if a2, ok := list["attendees"].([]any); ok && len(a2) != 0 {
|
||||
t.Errorf("after delete: got %d, want 0", len(a2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInTicketHandler(t *testing.T) {
|
||||
func TestCheckInAttendeeHandler(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Oberon", Source: "manual"})
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
app.db.Exec(`UPDATE attendees SET party_size = 3 WHERE id = 1`)
|
||||
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
// Check in 1
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", map[string]int{"count": 1}, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
ticket := result["ticket"].(map[string]any)
|
||||
if ticket["checked_in_at"] == nil {
|
||||
t.Error("checked_in_at should be set after check-in")
|
||||
attendee := result["attendee"].(map[string]any)
|
||||
if attendee["checked_in_count"] != float64(1) {
|
||||
t.Errorf("checked_in_count = %v, want 1", attendee["checked_in_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
||||
func TestGateRoleCanCheckIn(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
tk, _ := app.createTicket(Ticket{ParticipantID: &p.ID, Name: "Puck", Source: "manual"})
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
|
||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
||||
req := testAuthRequest("POST", "/api/attendees/1/checkin", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("gatekeeper checkin: status = %d", w.Code)
|
||||
t.Errorf("gate checkin: status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
||||
func TestGateRoleCannotDelete(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
||||
app.createAttendee(Attendee{Name: "Puck"})
|
||||
|
||||
req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token)
|
||||
req := testAuthRequest("DELETE", "/api/attendees/1", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("gatekeeper delete: status = %d, want 403", w.Code)
|
||||
t.Errorf("gate delete: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
|
||||
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
|
@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
user, hash, err := app.getLoginParticipant(body.Email)
|
||||
user, hash, err := app.getUserByUsername(body.Username)
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFromContext(r)
|
||||
user, err := app.getUser(claims.ParticipantID)
|
||||
user, err := app.getUserByID(claims.UserID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, user)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
type ImportResult struct {
|
||||
Inserted int `json:"inserted"`
|
||||
Grouped int `json:"grouped"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
|
@ -56,14 +57,12 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
}
|
||||
|
||||
var (
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int
|
||||
hasEmail, hasTicketID, hasTicketType bool
|
||||
isCrowdWork bool
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int
|
||||
hasEmail, hasTicketID, hasTicketType, hasNote bool
|
||||
)
|
||||
|
||||
if idx, ok := colIndex["patron name"]; ok {
|
||||
// CrowdWork / ticketing platform format
|
||||
isCrowdWork = true
|
||||
nameIdx = idx
|
||||
if idx, ok := colIndex["patron email"]; ok {
|
||||
emailIdx, hasEmail = idx, true
|
||||
|
|
@ -86,6 +85,9 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
if idx, ok := colIndex["ticket_type"]; ok {
|
||||
ticketTypeIdx, hasTicketType = idx, true
|
||||
}
|
||||
if idx, ok := colIndex["note"]; ok {
|
||||
noteIdx, hasNote = idx, true
|
||||
}
|
||||
} else {
|
||||
return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column")
|
||||
}
|
||||
|
|
@ -109,49 +111,33 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
email := ""
|
||||
a := Attendee{Name: name}
|
||||
if hasEmail {
|
||||
email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
a.Email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
}
|
||||
externalID := ""
|
||||
if hasTicketID {
|
||||
externalID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
}
|
||||
ticketType := ""
|
||||
if hasTicketType {
|
||||
ticketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
}
|
||||
if hasNote {
|
||||
a.Note = strings.TrimSpace(csvGet(record, noteIdx))
|
||||
}
|
||||
|
||||
source := "manual"
|
||||
orderID := ""
|
||||
if isCrowdWork {
|
||||
source = "crowdwork"
|
||||
orderID = externalID
|
||||
}
|
||||
|
||||
// Find or create participant when email is present.
|
||||
var participantID *int
|
||||
if email != "" {
|
||||
p, _, err := app.upsertParticipant(email, name)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): participant: %v", lineNum, name, err))
|
||||
continue
|
||||
}
|
||||
if p != nil {
|
||||
participantID = &p.ID
|
||||
}
|
||||
}
|
||||
|
||||
_, err = app.createTicket(Ticket{
|
||||
ParticipantID: participantID,
|
||||
Name: name,
|
||||
TicketType: ticketType,
|
||||
Source: source,
|
||||
ExternalID: externalID,
|
||||
OrderID: orderID,
|
||||
})
|
||||
_, err = app.createAttendee(a)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
// CrowdWork exports one row per ticket under the purchaser's name.
|
||||
// If we have a ticket_id and the same (name, ticket_id) already exists,
|
||||
// increment party_size instead of skipping.
|
||||
if hasTicketID && a.TicketID != "" {
|
||||
merged, mergeErr := app.incrementPartySize(a.Name, a.TicketID)
|
||||
if mergeErr == nil && merged {
|
||||
result.Grouped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.Skipped++
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
||||
|
|
|
|||
|
|
@ -61,13 +61,13 @@ func TestImportGenericFormat(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestImportDedup(t *testing.T) {
|
||||
func TestImportPartySizeDedup(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// 3 rows with same order number: first inserts, remaining 2 skip (same external_id)
|
||||
// 3 rows same name+order = 1 record, party_size=3
|
||||
csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\n"
|
||||
w := postCSV(t, mux, token, csv)
|
||||
|
||||
|
|
@ -75,16 +75,16 @@ func TestImportDedup(t *testing.T) {
|
|||
if result["inserted"] != float64(1) {
|
||||
t.Errorf("inserted = %v, want 1", result["inserted"])
|
||||
}
|
||||
if result["skipped"] != float64(2) {
|
||||
t.Errorf("skipped = %v, want 2", result["skipped"])
|
||||
if result["grouped"] != float64(2) {
|
||||
t.Errorf("grouped = %v, want 2", result["grouped"])
|
||||
}
|
||||
|
||||
tickets, _ := app.listTickets(nil, "")
|
||||
if len(tickets) != 1 {
|
||||
t.Fatalf("ticket count = %d, want 1", len(tickets))
|
||||
attendees, _ := app.listAttendees("", "", "")
|
||||
if len(attendees) != 1 {
|
||||
t.Fatalf("attendee count = %d, want 1", len(attendees))
|
||||
}
|
||||
if tickets[0].Source != "crowdwork" {
|
||||
t.Errorf("source = %q, want crowdwork", tickets[0].Source)
|
||||
if attendees[0].PartySize != 3 {
|
||||
t.Errorf("party_size = %d, want 3", attendees[0].PartySize)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +94,7 @@ func TestImportReimportSkips(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Use ticket_ids so re-import dedup works via UNIQUE(source, external_id)
|
||||
csv := "name,email,ticket_id\nTitania,titania@test.com,T001\nOberon,oberon@test.com,T002\n"
|
||||
csv := "name\nTitania\nOberon\n"
|
||||
postCSV(t, mux, token, csv)
|
||||
|
||||
// Re-import same data
|
||||
|
|
|
|||
|
|
@ -6,21 +6,23 @@ import (
|
|||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
|
||||
return app.getVolunteerByKioskCode(token)
|
||||
}
|
||||
|
||||
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
||||
// available open shifts in their department. Authenticated by kiosk code only —
|
||||
// available open shifts in their department. Authenticated by volunteer token only —
|
||||
// no JWT required.
|
||||
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer record linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
||||
if assigned == nil {
|
||||
assigned = []Shift{}
|
||||
|
|
@ -51,11 +53,16 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
force := r.URL.Query().Get("force") == "true"
|
||||
|
||||
|
|
@ -103,11 +110,16 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
|
|||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
|
||||
// Create volunteer with a kiosk_code directly on the volunteer record
|
||||
p, _ := app.createParticipant(Participant{Email: "titania@test.com", PreferredName: "Titania"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
token, _ := app.generateVolunteerKioskCode()
|
||||
app.assignKioskCode(v.ID, token)
|
||||
// Create attendee with token
|
||||
a, _ := app.createAttendee(Attendee{Name: "Titania", Email: "titania@test.com"})
|
||||
token, _ := app.generateUniqueToken()
|
||||
app.db.Exec(`UPDATE attendees SET volunteer_token = ? WHERE id = ?`, token, a.ID)
|
||||
|
||||
// Create linked volunteer
|
||||
app.createVolunteer(Volunteer{Name: "Titania", AttendeeID: &a.ID, DepartmentID: &deptID})
|
||||
|
||||
// Create shifts
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
||||
|
|
@ -132,8 +134,7 @@ func TestKioskClaimFull(t *testing.T) {
|
|||
// Shift 2 has capacity 1. Fill it with another volunteer.
|
||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
||||
deptID := dept.ID
|
||||
otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"})
|
||||
other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID})
|
||||
other, _ := app.createVolunteer(Volunteer{Name: "Other", DepartmentID: &deptID})
|
||||
app.assignShift(other.ID, 2) // fills the capacity-1 shift
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil)
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
search := r.URL.Query().Get("search")
|
||||
participants, err := app.listParticipants(search, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
total, checkedIn, _ := app.ticketCounts()
|
||||
types, _ := app.ticketTypes()
|
||||
writeJSON(w, map[string]any{
|
||||
"participants": participants,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
"ticket_types": types,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleGetParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p, err := app.getParticipant(id)
|
||||
if err != nil || p == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
tickets, _ := app.listTickets(&id, "")
|
||||
writeJSON(w, map[string]any{"participant": p, "tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
var p Participant
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.PreferredName == "" && p.Email == "" {
|
||||
writeError(w, "name or email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createParticipant(p)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var p Participant
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.ID = id
|
||||
if err := app.updateParticipant(p); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getParticipant(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteParticipant(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleMergeParticipants reassigns all tickets and volunteers from otherID to
|
||||
// canonicalID, then soft-deletes the other participant.
|
||||
func (app *App) handleMergeParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
otherID, err := strconv.Atoi(r.PathValue("other_id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid other_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.mergeParticipants(id, otherID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p, _ := app.getParticipant(id)
|
||||
tickets, _ := app.listTickets(&id, "")
|
||||
writeJSON(w, map[string]any{"participant": p, "tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
participants, err := app.listParticipants("", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="participants.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"id", "email", "preferred_name", "phone", "pronouns", "note"})
|
||||
for _, p := range participants {
|
||||
wr.Write([]string{
|
||||
strconv.Itoa(p.ID), p.Email, p.PreferredName, p.Phone, p.Pronouns, p.Note,
|
||||
})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
||||
func (app *App) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var t Ticket
|
||||
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if t.ParticipantID == nil {
|
||||
writeError(w, "participant_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if t.Source == "" {
|
||||
t.Source = "manual"
|
||||
}
|
||||
created, err := app.createTicket(t)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"tickets": tickets})
|
||||
}
|
||||
|
||||
func (app *App) handleCheckInTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
tk, err := app.checkInTicket(id, claims.ParticipantID)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
app.broker.publish("checkin", map[string]any{"type": "ticket", "ticket": tk})
|
||||
writeJSON(w, map[string]any{"ticket": tk})
|
||||
}
|
||||
|
|
@ -19,22 +19,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|||
pass = "***"
|
||||
}
|
||||
|
||||
var noteLabel, noteRequired, signupsOpen string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
||||
if noteLabel == "" {
|
||||
noteLabel = "Additional note"
|
||||
}
|
||||
|
||||
var ssoURL, ssoSecret string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
|
||||
maskedSSOSecret := ""
|
||||
if ssoSecret != "" {
|
||||
maskedSSOSecret = "***"
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"smtp_host": cfg.Host,
|
||||
"smtp_port": cfg.Port,
|
||||
|
|
@ -43,11 +27,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|||
"smtp_from": cfg.From,
|
||||
"smtp_from_name": cfg.FromName,
|
||||
"base_url": baseURL,
|
||||
"volunteer_note_label": noteLabel,
|
||||
"volunteer_note_required": noteRequired == "true",
|
||||
"shift_signups_open": signupsOpen == "true",
|
||||
"discourse_sso_url": ssoURL,
|
||||
"discourse_sso_secret": maskedSSOSecret,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -58,8 +37,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
|
||||
"volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"}
|
||||
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"}
|
||||
for _, k := range keys {
|
||||
v, ok := body[k]
|
||||
if !ok {
|
||||
|
|
@ -68,18 +46,12 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
var val string
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
|
||||
continue
|
||||
if k == "smtp_password" && vv == "" {
|
||||
continue // don't erase the stored password with an empty value
|
||||
}
|
||||
val = vv
|
||||
case float64:
|
||||
val = strconv.Itoa(int(vv))
|
||||
case bool:
|
||||
if vv {
|
||||
val = "true"
|
||||
} else {
|
||||
val = "false"
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
|
@ -89,66 +61,6 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
app.handleGetSettings(w, r)
|
||||
}
|
||||
|
||||
func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetVolunteers(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE volunteers SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetShifts(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
// Also soft-delete orphaned volunteer_shifts
|
||||
app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND shift_id IN (SELECT id FROM shifts WHERE deleted_at IS NOT NULL)`, ts, ts)
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
// Soft-delete shifts in these departments first (so sync picks them up)
|
||||
app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND shift_id IN (SELECT id FROM shifts WHERE department_id IN (SELECT id FROM departments WHERE deleted_at IS NULL))`, ts, ts)
|
||||
app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND department_id IN (SELECT id FROM departments WHERE deleted_at IS NULL)`, ts, ts)
|
||||
result, err := app.db.Exec(`UPDATE departments SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetVolunteerShifts(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
|
|
|
|||
|
|
@ -55,83 +55,9 @@ func TestUpdateSettings(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResetTickets(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
p2, _ := app.createParticipant(Participant{PreferredName: "Oberon", Email: "oberon@example.com"})
|
||||
app.createTicket(Ticket{ParticipantID: &p1.ID, Name: "Titania", Source: "manual"})
|
||||
app.createTicket(Ticket{ParticipantID: &p2.ID, Name: "Oberon", Source: "manual"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["deleted"] != float64(2) {
|
||||
t.Fatalf("deleted = %v, want 2", result["deleted"])
|
||||
}
|
||||
|
||||
tickets, _ := app.listTickets(nil, "")
|
||||
if len(tickets) != 0 {
|
||||
t.Fatalf("tickets remaining = %d, want 0", len(tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetTicketsRequiresAdmin(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRoles(t, app, "Snug", []string{"gatekeeper"}, []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-tickets", nil, token))
|
||||
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetDepartmentsCascadesShifts(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Rangers"})
|
||||
app.createShift(Shift{DepartmentID: dept.ID, Day: "2026-03-01", StartTime: "09:00", EndTime: "12:00", Capacity: 5})
|
||||
|
||||
shifts, _ := app.listShifts(nil, "", "")
|
||||
if len(shifts) != 1 {
|
||||
t.Fatalf("shifts before reset = %d, want 1", len(shifts))
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/reset-departments", nil, token))
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
depts, _ := app.listDepartments("")
|
||||
if len(depts) != 0 {
|
||||
t.Fatalf("departments remaining = %d, want 0", len(depts))
|
||||
}
|
||||
|
||||
shifts, _ = app.listShifts(nil, "", "")
|
||||
if len(shifts) != 0 {
|
||||
t.Fatalf("shifts should cascade-delete, remaining = %d", len(shifts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsNonAdminRejected(t *testing.T) {
|
||||
app := testApp(t)
|
||||
gate := testUserWithRoles(t, app, "Quince", []string{"gatekeeper"}, []int{})
|
||||
gate := testUserWithRole(t, app, "gateuser", "gate", []int{})
|
||||
token := testToken(t, app, gate)
|
||||
mux := testMux(app)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,19 +8,20 @@ import (
|
|||
|
||||
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var deptIDs []int
|
||||
var deptID *int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||
deptIDs = claims.DeptIDs
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
}
|
||||
|
||||
shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since"))
|
||||
shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -39,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -64,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
if claims.Role == "volunteer_lead" {
|
||||
existing, _ := app.getShift(id)
|
||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
|
|
@ -86,14 +87,6 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(id)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.deleteShift(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -118,14 +111,6 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
|
|||
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(shiftID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !body.Force {
|
||||
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
||||
|
|
@ -164,14 +149,6 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ
|
|||
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(shiftID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -190,16 +167,6 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
for _, p := range raw {
|
||||
s, _ := app.getShift(p.ID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
positions := make([]struct{ ID, Position int }, len(raw))
|
||||
for i, p := range raw {
|
||||
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,7 @@ func TestShiftAssignVolunteer(t *testing.T) {
|
|||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
||||
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||
|
||||
// Assign
|
||||
req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{
|
||||
|
|
@ -87,8 +86,7 @@ func TestShiftAssignConflict(t *testing.T) {
|
|||
deptID := dept.ID
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
||||
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||
|
||||
// Assign to first shift
|
||||
app.assignShift(1, 1)
|
||||
|
|
@ -104,86 +102,6 @@ func TestShiftAssignConflict(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCoLeadDeleteShiftOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadDeleteShiftOwnDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
deptBID := deptB.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{
|
||||
"volunteer_id": v.ID,
|
||||
}, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadReorderShiftsOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{
|
||||
{"id": s1.ID, "position": 2},
|
||||
{"id": s2.ID, "position": 1},
|
||||
}, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept reorder, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShiftReorder(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
|
|
|
|||
212
handle_signup.go
212
handle_signup.go
|
|
@ -1,212 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (app *App) handlePublicSignupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var noteLabel, noteRequired string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
|
||||
if noteLabel == "" {
|
||||
noteLabel = "Additional note"
|
||||
}
|
||||
|
||||
depts, _ := app.listDepartments("")
|
||||
deptList := []map[string]any{}
|
||||
for _, d := range depts {
|
||||
deptList = append(deptList, map[string]any{"id": d.ID, "name": d.Name, "color": d.Color})
|
||||
}
|
||||
|
||||
eventName := app.eventName()
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"event_name": eventName,
|
||||
"departments": deptList,
|
||||
"volunteer_note_label": noteLabel,
|
||||
"volunteer_note_required": noteRequired == "true",
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handlePublicSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
PreferredName string `json:"preferred_name"`
|
||||
TicketName string `json:"ticket_name"`
|
||||
Email string `json:"email"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
Phone string `json:"phone"`
|
||||
DepartmentID *int `json:"department_id"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body.PreferredName = strings.TrimSpace(body.PreferredName)
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
if body.PreferredName == "" || body.Email == "" {
|
||||
writeError(w, "preferred name and email are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var noteRequired string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
|
||||
if noteRequired == "true" && strings.TrimSpace(body.Note) == "" {
|
||||
writeError(w, "note field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't reveal whether email is already registered
|
||||
existing, _ := app.getVolunteerByEmail(body.Email)
|
||||
if existing != nil {
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
// Find or create participant by email.
|
||||
participant, _, err := app.upsertParticipant(body.Email, body.PreferredName)
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Update participant's personal details if they signed up with more info.
|
||||
if body.Phone != "" || body.Pronouns != "" || body.TicketName != "" {
|
||||
app.db.Exec(`UPDATE participants SET
|
||||
phone = CASE WHEN phone = '' THEN ? ELSE phone END,
|
||||
pronouns = CASE WHEN pronouns = '' THEN ? ELSE pronouns END,
|
||||
ticket_name = CASE WHEN ticket_name = '' THEN ? ELSE ticket_name END,
|
||||
updated_at = ?
|
||||
WHERE id = ?`, body.Phone, body.Pronouns, body.TicketName, now(), participant.ID)
|
||||
}
|
||||
|
||||
confirmToken, err := generateConfirmationToken()
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
app.setParticipantConfirmationToken(participant.ID, confirmToken)
|
||||
|
||||
vol := Volunteer{
|
||||
ParticipantID: participant.ID,
|
||||
DepartmentID: body.DepartmentID,
|
||||
Note: body.Note,
|
||||
}
|
||||
|
||||
if _, err := app.createVolunteer(vol); err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := app.sendConfirmationEmail(body.Email, body.PreferredName, confirmToken); err != nil {
|
||||
log.Printf("confirmation email to %s failed: %v", body.Email, err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (app *App) handleConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token == "" {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vol, err := app.getVolunteerByConfirmationToken(body.Token)
|
||||
if err != nil || vol == nil {
|
||||
writeJSON(w, map[string]any{"status": "invalid"})
|
||||
return
|
||||
}
|
||||
|
||||
if vol.EmailConfirmed {
|
||||
writeJSON(w, map[string]any{"status": "already_confirmed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.confirmParticipantEmail(vol.ParticipantID); err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{"status": "confirmed"}
|
||||
|
||||
var signupsOpen string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
||||
|
||||
if signupsOpen == "true" {
|
||||
code, err := app.generateVolunteerKioskCode()
|
||||
if err == nil {
|
||||
if err := app.assignKioskCode(vol.ID, code); err == nil {
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), code)
|
||||
response["kiosk_link"] = kioskLink
|
||||
go func() {
|
||||
if err := app.sendShiftSignupEmail(vol.Email, vol.Name, kioskLink); err != nil {
|
||||
log.Printf("shift signup email to %s failed: %v", vol.Email, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (app *App) handleToggleShiftSignups(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
val := "false"
|
||||
if body.Open {
|
||||
val = "true"
|
||||
}
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', ?)`, val)
|
||||
|
||||
if body.Open {
|
||||
go app.openShiftSignups()
|
||||
}
|
||||
writeJSON(w, map[string]any{"shift_signups_open": body.Open})
|
||||
}
|
||||
|
||||
func (app *App) openShiftSignups() {
|
||||
// Assign kiosk codes to email-confirmed volunteers that don't have one yet.
|
||||
vols, _ := app.listVolunteersNeedingKioskCode()
|
||||
for _, v := range vols {
|
||||
code, err := app.generateVolunteerKioskCode()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
app.assignKioskCode(v.ID, code)
|
||||
}
|
||||
|
||||
// Email all email-confirmed volunteers that now have a kiosk code.
|
||||
confirmed, _ := queryVolunteers(app.db, `
|
||||
SELECT `+volunteerSelect+` `+volunteerFrom+`
|
||||
WHERE p.email_confirmed = 1 AND v.kiosk_code IS NOT NULL AND v.deleted_at IS NULL`)
|
||||
baseURL := app.resolveBaseURL()
|
||||
sent := 0
|
||||
|
||||
for _, v := range confirmed {
|
||||
if v.Email == "" || v.KioskCode == nil {
|
||||
continue
|
||||
}
|
||||
kioskLink := fmt.Sprintf("%s/v/%s", baseURL, *v.KioskCode)
|
||||
if err := app.sendShiftSignupEmail(v.Email, v.Name, kioskLink); err == nil {
|
||||
sent++
|
||||
} else {
|
||||
log.Printf("shift signup email to %s failed: %v", v.Email, err)
|
||||
}
|
||||
}
|
||||
log.Printf("Shift signups opened: sent %d emails", sent)
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublicSignupConfig(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
app.createDepartment(Department{Name: "Setup", Color: "#ff0000"})
|
||||
app.createDepartment(Department{Name: "Teardown", Color: "#00ff00"})
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_label', 'Who sent you?')`)
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("GET", "/api/public/signup-config", nil))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
depts, ok := result["departments"].([]any)
|
||||
if !ok || len(depts) != 2 {
|
||||
t.Fatalf("expected 2 departments, got %v", result["departments"])
|
||||
}
|
||||
if result["volunteer_note_label"] != "Who sent you?" {
|
||||
t.Errorf("expected 'Who sent you?', got %v", result["volunteer_note_label"])
|
||||
}
|
||||
if result["volunteer_note_required"] != true {
|
||||
t.Errorf("expected note required true, got %v", result["volunteer_note_required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignup(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
app.createDepartment(Department{Name: "Setup", Color: "#ff0000"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
deptID := 1
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"email": "titania@example.com",
|
||||
"pronouns": "she/they",
|
||||
"department_id": deptID,
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["ok"] != true {
|
||||
t.Fatalf("expected ok true, got %v", result)
|
||||
}
|
||||
|
||||
// Volunteer should exist
|
||||
vol, err := app.getVolunteerByEmail("titania@example.com")
|
||||
if err != nil || vol == nil {
|
||||
t.Fatal("volunteer not created")
|
||||
}
|
||||
if vol.Name != "Titania" {
|
||||
t.Errorf("name = %q, want Titania", vol.Name)
|
||||
}
|
||||
if vol.Pronouns != "she/they" {
|
||||
t.Errorf("pronouns = %q, want she/they", vol.Pronouns)
|
||||
}
|
||||
if vol.EmailConfirmed {
|
||||
t.Error("should not be confirmed yet")
|
||||
}
|
||||
|
||||
// Participant should be auto-created and linked
|
||||
if vol.ParticipantID == 0 {
|
||||
t.Fatal("expected participant to be linked")
|
||||
}
|
||||
p, _ := app.getParticipant(vol.ParticipantID)
|
||||
if p == nil {
|
||||
t.Fatal("linked participant not found")
|
||||
}
|
||||
if p.ConfirmationToken == nil || *p.ConfirmationToken == "" {
|
||||
t.Error("expected confirmation token on participant")
|
||||
}
|
||||
if p.Email != "titania@example.com" {
|
||||
t.Errorf("participant email = %q, want titania@example.com", p.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupAutoMatchParticipant(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
// Pre-existing participant
|
||||
existing, _ := app.createParticipant(Participant{PreferredName: "Titania Fairweather", Email: "titania@example.com"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"ticket_name": "Titania Fairweather",
|
||||
"email": "titania@example.com",
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||
if vol == nil {
|
||||
t.Fatal("volunteer not created")
|
||||
}
|
||||
if vol.ParticipantID == 0 || vol.ParticipantID != existing.ID {
|
||||
t.Errorf("expected volunteer linked to existing participant %d, got %d", existing.ID, vol.ParticipantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupDuplicateEmail(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
// First signup
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"email": "titania@example.com",
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("first signup: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Second signup with same email — should silently succeed
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Puck",
|
||||
"email": "titania@example.com",
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("duplicate signup: expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["ok"] != true {
|
||||
t.Fatalf("expected ok true for duplicate, got %v", result)
|
||||
}
|
||||
|
||||
// Should still be only one volunteer
|
||||
vols, _ := app.listVolunteers("", nil, "")
|
||||
if len(vols) != 1 {
|
||||
t.Errorf("expected 1 volunteer, got %d", len(vols))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupMissingFields(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"no name", map[string]any{"email": "a@b.com"}},
|
||||
{"no email", map[string]any{"preferred_name": "Titania"}},
|
||||
{"empty both", map[string]any{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", tt.body))
|
||||
if w.Code != 400 {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupNoteRequired(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('volunteer_note_required', 'true')`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"email": "titania@example.com",
|
||||
"note": "",
|
||||
}))
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("expected 400 when note required but empty, got %d", w.Code)
|
||||
}
|
||||
|
||||
// With note provided
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"email": "titania@example.com",
|
||||
"note": "A friend sent me",
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200 with note, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmail(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
token := "abc123def456"
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
||||
app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["status"] != "confirmed" {
|
||||
t.Errorf("expected confirmed, got %v", result["status"])
|
||||
}
|
||||
|
||||
// Verify participant is email confirmed
|
||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||
if vol == nil || !vol.EmailConfirmed {
|
||||
t.Error("volunteer should show email confirmed via participant")
|
||||
}
|
||||
updatedP, _ := app.getParticipant(p.ID)
|
||||
if updatedP.ConfirmationToken != nil {
|
||||
t.Error("confirmation token should be cleared after confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmailInvalid(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": "nonexistent"}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["status"] != "invalid" {
|
||||
t.Errorf("expected invalid, got %v", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmailAlreadyConfirmed(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
token := "abc123def456"
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
||||
app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||
|
||||
// Confirm first time
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||
if parseJSON(t, w)["status"] != "confirmed" {
|
||||
t.Fatal("first confirm should succeed")
|
||||
}
|
||||
|
||||
// Second confirm with same token should be invalid (token cleared)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||
result := parseJSON(t, w)
|
||||
if result["status"] != "invalid" {
|
||||
t.Errorf("expected invalid after token cleared, got %v", result["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmailWithSignupsOpen(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
||||
app.baseURL = "https://example.com"
|
||||
|
||||
token := "abc123def456"
|
||||
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com", ConfirmationToken: &token})
|
||||
app.createVolunteer(Volunteer{ParticipantID: participant.ID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["status"] != "confirmed" {
|
||||
t.Fatalf("expected confirmed, got %v", result["status"])
|
||||
}
|
||||
kioskLink, ok := result["kiosk_link"].(string)
|
||||
if !ok || kioskLink == "" {
|
||||
t.Error("expected kiosk_link when signups are open")
|
||||
}
|
||||
|
||||
// Volunteer should now have a kiosk_code, no stub ticket created.
|
||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||
if vol == nil || vol.KioskCode == nil {
|
||||
t.Error("volunteer should have a kiosk_code after confirm with signups open")
|
||||
}
|
||||
tickets, _ := app.listTickets(&participant.ID, "")
|
||||
if len(tickets) != 0 {
|
||||
t.Errorf("expected no stub tickets, got %d", len(tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSignupTicketNameDoesNotOverwritePreferredName(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/signup", map[string]any{
|
||||
"preferred_name": "Titania",
|
||||
"ticket_name": "Titania Fairweather",
|
||||
"email": "titania@example.com",
|
||||
}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||
if vol == nil || vol.ParticipantID == 0 {
|
||||
t.Fatal("volunteer/participant not created")
|
||||
}
|
||||
p, _ := app.getParticipant(vol.ParticipantID)
|
||||
if p == nil {
|
||||
t.Fatal("participant not found")
|
||||
}
|
||||
if p.PreferredName != "Titania" {
|
||||
t.Errorf("participant preferred_name = %q, want %q (not ticket_name)", p.PreferredName, "Titania")
|
||||
}
|
||||
if p.TicketName != "Titania Fairweather" {
|
||||
t.Errorf("participant.TicketName = %q, want %q", p.TicketName, "Titania Fairweather")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmailAssignsKioskCode(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
app.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES ('shift_signups_open', 'true')`)
|
||||
app.baseURL = "https://example.com"
|
||||
|
||||
token := "abc123def456"
|
||||
participant, _ := app.createParticipant(Participant{PreferredName: "Titania", TicketName: "Titania Fairweather", Email: "titania@example.com", ConfirmationToken: &token})
|
||||
app.createVolunteer(Volunteer{ParticipantID: participant.ID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testRequest("POST", "/api/public/confirm", map[string]any{"token": token}))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["status"] != "confirmed" {
|
||||
t.Fatalf("expected confirmed, got %v", result["status"])
|
||||
}
|
||||
if result["kiosk_link"] == nil {
|
||||
t.Error("expected kiosk_link in response when signups are open")
|
||||
}
|
||||
|
||||
// Kiosk code should be on the volunteer record, not a stub ticket.
|
||||
vol, _ := app.getVolunteerByEmail("titania@example.com")
|
||||
if vol == nil || vol.KioskCode == nil {
|
||||
t.Fatal("expected volunteer to have a kiosk_code")
|
||||
}
|
||||
// No stub ticket should have been created.
|
||||
tickets, _ := app.listTickets(&participant.ID, "")
|
||||
if len(tickets) != 0 {
|
||||
t.Errorf("expected no stub tickets, got %d", len(tickets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleShiftSignups(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
admin := testAdminUser(t, app)
|
||||
tok := testToken(t, app, admin)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/settings/shift-signups", map[string]any{"open": true}, tok))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["shift_signups_open"] != true {
|
||||
t.Errorf("expected shift_signups_open true, got %v", result)
|
||||
}
|
||||
|
||||
// Check config stored
|
||||
var val string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&val)
|
||||
if val != "true" {
|
||||
t.Errorf("config not stored, got %q", val)
|
||||
}
|
||||
}
|
||||
190
handle_sso.go
190
handle_sso.go
|
|
@ -1,190 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (app *App) getSSOConfig() (ssoURL, ssoSecret string) {
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
ssoURL, ssoSecret := app.getSSOConfig()
|
||||
writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""})
|
||||
}
|
||||
|
||||
func (app *App) getBaseURL() string {
|
||||
if app.baseURL != "" {
|
||||
return app.baseURL
|
||||
}
|
||||
var u string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u)
|
||||
return u
|
||||
}
|
||||
|
||||
func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) {
|
||||
ssoURL, ssoSecret := app.getSSOConfig()
|
||||
if ssoURL == "" || ssoSecret == "" {
|
||||
writeError(w, "SSO not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := app.getBaseURL()
|
||||
if baseURL == "" {
|
||||
writeError(w, "base_url must be configured for SSO", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
nonce := hex.EncodeToString(b)
|
||||
|
||||
app.cleanExpiredNonces()
|
||||
if err := app.createSSONonce(nonce); err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback"
|
||||
|
||||
payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL))
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(ssoSecret))
|
||||
mac.Write([]byte(encoded))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
|
||||
strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig))
|
||||
|
||||
writeJSON(w, map[string]string{"redirect_url": redirect})
|
||||
}
|
||||
|
||||
func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||
baseURL := app.getBaseURL()
|
||||
|
||||
ssoRedirectError := func(msg string) {
|
||||
if baseURL != "" {
|
||||
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound)
|
||||
} else {
|
||||
writeError(w, msg, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
_, ssoSecret := app.getSSOConfig()
|
||||
if ssoSecret == "" {
|
||||
ssoRedirectError("SSO not configured")
|
||||
return
|
||||
}
|
||||
|
||||
ssoParam := r.URL.Query().Get("sso")
|
||||
sigParam := r.URL.Query().Get("sig")
|
||||
if ssoParam == "" || sigParam == "" {
|
||||
ssoRedirectError("Invalid SSO response")
|
||||
return
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(ssoSecret))
|
||||
mac.Write([]byte(ssoParam))
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) {
|
||||
ssoRedirectError("Invalid SSO signature")
|
||||
return
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(ssoParam)
|
||||
if err != nil {
|
||||
ssoRedirectError("Invalid SSO payload")
|
||||
return
|
||||
}
|
||||
|
||||
vals, err := url.ParseQuery(string(decoded))
|
||||
if err != nil {
|
||||
ssoRedirectError("Invalid SSO payload")
|
||||
return
|
||||
}
|
||||
|
||||
nonce := vals.Get("nonce")
|
||||
valid, err := app.consumeSSONonce(nonce)
|
||||
if err != nil || !valid {
|
||||
ssoRedirectError("SSO session expired. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(vals.Get("email"))
|
||||
if email == "" {
|
||||
ssoRedirectError("No email in SSO response")
|
||||
return
|
||||
}
|
||||
|
||||
name := vals.Get("name")
|
||||
if name == "" {
|
||||
name = vals.Get("username")
|
||||
}
|
||||
|
||||
user, _, err := app.getLoginParticipant(email)
|
||||
if err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
p, err := app.getParticipantByEmail(email)
|
||||
if err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
if p != nil {
|
||||
if _, err := app.db.Exec(
|
||||
`UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`,
|
||||
now(), p.ID,
|
||||
); err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
user, err = app.getUser(p.ID)
|
||||
if err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
if name == "" {
|
||||
name = strings.Split(email, "@")[0]
|
||||
}
|
||||
res, err := app.db.Exec(
|
||||
`INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
|
||||
email, name, now(),
|
||||
)
|
||||
if err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
user, err = app.getUser(int(id))
|
||||
if err != nil || user == nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := app.signToken(user)
|
||||
if err != nil {
|
||||
ssoRedirectError("Login failed. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound)
|
||||
}
|
||||
|
|
@ -12,18 +12,14 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
since := r.URL.Query().Get("since")
|
||||
|
||||
event, _ := app.getEvent()
|
||||
participants, _ := app.listParticipants("", since)
|
||||
tickets, _ := app.listTickets(nil, since)
|
||||
attendees, _ := app.attendeesSince(since)
|
||||
departments, _ := app.listDepartments(since)
|
||||
volunteers, _ := app.listVolunteers("", nil, since)
|
||||
shifts, _ := app.listShifts(nil, "", since)
|
||||
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||
|
||||
if participants == nil {
|
||||
participants = []Participant{}
|
||||
}
|
||||
if tickets == nil {
|
||||
tickets = []Ticket{}
|
||||
if attendees == nil {
|
||||
attendees = []Attendee{}
|
||||
}
|
||||
if departments == nil {
|
||||
departments = []Department{}
|
||||
|
|
@ -41,8 +37,7 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, map[string]any{
|
||||
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"event": event,
|
||||
"participants": participants,
|
||||
"tickets": tickets,
|
||||
"attendees": attendees,
|
||||
"departments": departments,
|
||||
"volunteers": volunteers,
|
||||
"shifts": shifts,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ func TestSyncPullFull(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
app.createVolunteer(Volunteer{Name: "Titania", DepartmentID: &deptID})
|
||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull", nil, token)
|
||||
|
|
@ -31,9 +31,9 @@ func TestSyncPullFull(t *testing.T) {
|
|||
if result["server_time"] == nil {
|
||||
t.Error("missing server_time")
|
||||
}
|
||||
participants := result["participants"].([]any)
|
||||
if len(participants) != 2 { // admin + Titania
|
||||
t.Errorf("participants = %d, want 2", len(participants))
|
||||
attendees := result["attendees"].([]any)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("attendees = %d, want 1", len(attendees))
|
||||
}
|
||||
depts := result["departments"].([]any)
|
||||
if len(depts) != 1 {
|
||||
|
|
@ -47,30 +47,29 @@ func TestSyncPullIncremental(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Backdate admin participant so it falls before the "since" cutoff.
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID)
|
||||
|
||||
p1, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p1.ID)
|
||||
app.createAttendee(Attendee{Name: "Titania"})
|
||||
// Backdate Titania so she falls before the "since" cutoff
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE name = 'Titania'`)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Lysander created with default updated_at (now), which is after our since
|
||||
app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@example.com"})
|
||||
// Oberon created with default updated_at (now), which is after our since
|
||||
app.createAttendee(Attendee{Name: "Oberon"})
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
result := parseJSON(t, w)
|
||||
participants := result["participants"].([]any)
|
||||
if len(participants) != 1 {
|
||||
t.Errorf("incremental: got %d participants, want 1", len(participants))
|
||||
attendees := result["attendees"].([]any)
|
||||
// Should only include Oberon (created after `since`)
|
||||
if len(attendees) != 1 {
|
||||
t.Errorf("incremental: got %d attendees, want 1", len(attendees))
|
||||
}
|
||||
if len(participants) == 1 {
|
||||
p := participants[0].(map[string]any)
|
||||
if p["preferred_name"] != "Lysander" {
|
||||
t.Errorf("preferred_name = %v, want Lysander", p["preferred_name"])
|
||||
if len(attendees) == 1 {
|
||||
a := attendees[0].(map[string]any)
|
||||
if a["name"] != "Oberon" {
|
||||
t.Errorf("name = %v, want Oberon", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,33 +80,31 @@ func TestSyncPullIncludesSoftDeleted(t *testing.T) {
|
|||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
// Backdate admin participant.
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, admin.ID)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
||||
app.db.Exec(`UPDATE participants SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, p.ID)
|
||||
a, _ := app.createAttendee(Attendee{Name: "Titania"})
|
||||
// Backdate Titania's creation so the since cutoff is between creation and deletion
|
||||
app.db.Exec(`UPDATE attendees SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`, a.ID)
|
||||
|
||||
since := "2026-01-01T12:00:00Z"
|
||||
|
||||
// Delete updates updated_at to now(), which is after our since
|
||||
app.deleteParticipant(p.ID)
|
||||
app.deleteAttendee(a.ID)
|
||||
|
||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
var result struct {
|
||||
Participants []struct {
|
||||
Attendees []struct {
|
||||
ID int `json:"id"`
|
||||
DeletedAt *string `json:"deleted_at"`
|
||||
} `json:"participants"`
|
||||
} `json:"attendees"`
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
|
||||
if len(result.Participants) != 1 {
|
||||
t.Fatalf("got %d participants, want 1", len(result.Participants))
|
||||
if len(result.Attendees) != 1 {
|
||||
t.Fatalf("got %d attendees, want 1", len(result.Attendees))
|
||||
}
|
||||
if result.Participants[0].DeletedAt == nil {
|
||||
if result.Attendees[0].DeletedAt == nil {
|
||||
t.Error("deleted_at should be set for soft-deleted record")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// handleGenerateTokens creates codes for all tickets that don't have one.
|
||||
// handleGenerateTokens creates volunteer_token values for all attendees that don't have one.
|
||||
func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := app.generateCodesForAll()
|
||||
count, err := app.generateTokensForAll()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
|||
// handleExportTokenLinks streams a CSV download with token signup links,
|
||||
// compatible with MailChimp / Zeffy bulk-send workflows.
|
||||
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -37,62 +37,55 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
for _, a := range attendees {
|
||||
if a.VolunteerToken == nil {
|
||||
continue
|
||||
}
|
||||
p, _ := app.getParticipant(*tk.ParticipantID)
|
||||
if p == nil || p.Email == "" {
|
||||
continue
|
||||
}
|
||||
firstName := p.PreferredName
|
||||
if firstName == "" {
|
||||
firstName = tk.Name
|
||||
}
|
||||
if parts := strings.Fields(firstName); len(parts) > 0 {
|
||||
firstName := a.Name
|
||||
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||
firstName = parts[0]
|
||||
}
|
||||
link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code)
|
||||
wr.Write([]string{p.Email, firstName, *tk.Code, link})
|
||||
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
||||
// handleEmailToken sends a token email to a single ticket's participant.
|
||||
// handleEmailToken sends a token email to a single attendee.
|
||||
func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tk, err := app.getTicket(id)
|
||||
if err != nil || tk == nil {
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := app.sendTicketTokenEmail(*tk); err != nil {
|
||||
if err := app.sendTokenEmail(*a); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleEmailAllTokens bulk-sends token emails to all tickets that have a code and participant email.
|
||||
// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email.
|
||||
func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var sent, skipped int
|
||||
var errors []string
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
for _, a := range attendees {
|
||||
if a.Email == "" || a.VolunteerToken == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := app.sendTicketTokenEmail(tk); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err))
|
||||
if err := app.sendTokenEmail(a); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err))
|
||||
skipped++
|
||||
} else {
|
||||
sent++
|
||||
|
|
|
|||
|
|
@ -17,18 +17,17 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Roles []string `json:"roles"`
|
||||
Role string `json:"role"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Email == "" || body.Password == "" || len(body.Roles) == 0 {
|
||||
writeError(w, "email, password, and at least one role are required", http.StatusBadRequest)
|
||||
if body.Username == "" || body.Password == "" || body.Role == "" {
|
||||
writeError(w, "username, password, and role are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(body.Password)
|
||||
|
|
@ -39,7 +38,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if body.DepartmentIDs == nil {
|
||||
body.DepartmentIDs = []int{}
|
||||
}
|
||||
user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs)
|
||||
user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -54,13 +53,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target, _ := app.getUser(id)
|
||||
if target == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Roles []string `json:"roles"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
}
|
||||
|
|
@ -71,8 +65,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if body.DepartmentIDs == nil {
|
||||
body.DepartmentIDs = []int{}
|
||||
}
|
||||
if body.Roles != nil {
|
||||
if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil {
|
||||
if body.Role != "" {
|
||||
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -88,7 +82,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
user, _ := app.getUser(id)
|
||||
user, _ := app.getUserByID(id)
|
||||
writeJSON(w, user)
|
||||
}
|
||||
|
||||
|
|
@ -99,11 +93,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.ParticipantID == id {
|
||||
if claims.UserID == id {
|
||||
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.removeUser(id); err != nil {
|
||||
if err := app.deleteUser(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
|
@ -12,19 +11,20 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
search := q.Get("search")
|
||||
since := q.Get("since")
|
||||
|
||||
var deptIDs []int
|
||||
var deptID *int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||
deptIDs = claims.DeptIDs
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
}
|
||||
|
||||
volunteers, err := app.listVolunteers(search, deptIDs, since)
|
||||
volunteers, err := app.listVolunteers(search, deptID, since)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -33,65 +33,27 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
TicketName string `json:"ticket_name"`
|
||||
Email string `json:"email"`
|
||||
DepartmentID *int `json:"department_id"`
|
||||
IsLead bool `json:"is_lead"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var v Volunteer
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
if v.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Email == "" {
|
||||
writeError(w, "email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
p, _ := app.getParticipantByEmail(body.Email)
|
||||
if p == nil {
|
||||
p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName})
|
||||
} else if body.TicketName != "" && p.TicketName == "" {
|
||||
app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID)
|
||||
}
|
||||
if p == nil {
|
||||
writeError(w, "failed to create participant", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
confirmToken, err := generateConfirmationToken()
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
app.setParticipantConfirmationToken(p.ID, confirmToken)
|
||||
v := Volunteer{
|
||||
ParticipantID: p.ID,
|
||||
DepartmentID: body.DepartmentID,
|
||||
IsLead: body.IsLead,
|
||||
Note: body.Note,
|
||||
}
|
||||
created, err := app.createVolunteer(v)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil {
|
||||
log.Printf("confirmation email to %s failed: %v", body.Email, err)
|
||||
}
|
||||
}()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
|
@ -116,40 +78,28 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
DepartmentID *int `json:"department_id"`
|
||||
IsLead bool `json:"is_lead"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var v Volunteer
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
if claims.Role == "volunteer_lead" {
|
||||
existing, _ := app.getVolunteer(id)
|
||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v := Volunteer{
|
||||
ID: id,
|
||||
DepartmentID: body.DepartmentID,
|
||||
IsLead: body.IsLead,
|
||||
Note: body.Note,
|
||||
}
|
||||
v.ID = id
|
||||
if err := app.updateVolunteer(v); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if v.IsLead {
|
||||
app.confirmVolunteer(id)
|
||||
}
|
||||
updated, _ := app.getVolunteer(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
|
@ -160,14 +110,6 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.deleteVolunteer(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -175,21 +117,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := app.markVolunteerReady(id, claims.ParticipantID)
|
||||
v, err := app.checkInVolunteer(id, claims.UserID)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -198,28 +133,6 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
|
|||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := app.confirmVolunteer(id)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
|
@ -233,24 +146,7 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "shift_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(volunteerID)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
shift, err := app.getShift(body.ShiftID)
|
||||
if err != nil || shift == nil {
|
||||
writeError(w, "shift not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil {
|
||||
if err == errShiftFull {
|
||||
writeError(w, "shift is at capacity", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := app.assignShift(volunteerID, body.ShiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -268,14 +164,6 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(volunteerID)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -283,3 +171,11 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func inSlice(v int, s []int) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,264 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfirmVolunteer(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
admin := testAdminUser(t, app)
|
||||
tok := testToken(t, app, admin)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com", EmailConfirmed: true})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
result := parseJSON(t, w)
|
||||
vol := result["confirmed"]
|
||||
if vol != true {
|
||||
t.Error("expected confirmed=true in response")
|
||||
}
|
||||
|
||||
got, _ := app.getVolunteer(v.ID)
|
||||
if got == nil || !got.Confirmed {
|
||||
t.Error("volunteer should be confirmed in DB")
|
||||
}
|
||||
if got.ConfirmedAt == nil {
|
||||
t.Error("confirmed_at should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmVolunteerIdempotent(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
admin := testAdminUser(t, app)
|
||||
tok := testToken(t, app, admin)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com", EmailConfirmed: true})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||
|
||||
// Confirm twice — second should be a no-op, not an error.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("first confirm: %d", w.Code)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("second confirm: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmVolunteerRequiresRole(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
// Gatekeeper role should NOT be able to confirm volunteers.
|
||||
gatekeeper := testUserWithRoles(t, app, "Egeus", []string{"gatekeeper"}, []int{})
|
||||
tok := testToken(t, app, gatekeeper)
|
||||
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", EmailConfirmed: true})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for gatekeeper role, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadDeleteVolunteerOwnDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptAID := deptA.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadDeleteVolunteerOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptBID := deptB.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/volunteers/"+itoa(v.ID), nil, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadConfirmVolunteerOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptBID := deptB.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/confirm", nil, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadReadyVolunteerOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptBID := deptB.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/ready", nil, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadAssignShiftOtherDept(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptBID := deptB.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("POST", "/api/volunteers/"+itoa(v.ID)+"/shifts", map[string]any{
|
||||
"shift_id": s.ID,
|
||||
}, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for other dept, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoLeadUpdateVolunteerTargetDeptForbidden(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
deptA, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptB, _ := app.createDepartment(Department{Name: "Build"})
|
||||
colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
|
||||
tok := testToken(t, app, colead)
|
||||
|
||||
deptAID := deptA.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptAID})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||
"department_id": deptB.ID,
|
||||
}, tok))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 moving to other dept, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateVolunteerDepartment(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
admin := testAdminUser(t, app)
|
||||
tok := testToken(t, app, admin)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Hermia"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID})
|
||||
|
||||
// Assign department via update.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||
"department_id": dept.ID,
|
||||
}, tok))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
got, _ := app.getVolunteer(v.ID)
|
||||
if got.DepartmentID == nil || *got.DepartmentID != dept.ID {
|
||||
t.Errorf("department_id = %v, want %d", got.DepartmentID, dept.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateVolunteerCoLeadAutoConfirms(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
admin := testAdminUser(t, app)
|
||||
tok := testToken(t, app, admin)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
||||
deptID := dept.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lys@test.com", EmailConfirmed: true})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
|
||||
// Verify not confirmed before update.
|
||||
got, _ := app.getVolunteer(v.ID)
|
||||
if got.Confirmed {
|
||||
t.Fatal("should not be confirmed before update")
|
||||
}
|
||||
|
||||
// Update is_lead=true should auto-confirm.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, testAuthRequest("PUT", "/api/volunteers/"+itoa(v.ID), map[string]any{
|
||||
"department_id": deptID, "is_lead": true,
|
||||
}, tok))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
got, _ = app.getVolunteer(v.ID)
|
||||
if !got.IsLead {
|
||||
t.Error("expected is_lead=true")
|
||||
}
|
||||
if !got.Confirmed {
|
||||
t.Error("co-lead should be auto-confirmed")
|
||||
}
|
||||
}
|
||||
86
main.go
86
main.go
|
|
@ -99,44 +99,39 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin"))
|
||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin"))
|
||||
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin"))
|
||||
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin"))
|
||||
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin"))
|
||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin"))
|
||||
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing"))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
|
||||
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||
|
|
@ -146,13 +141,8 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin"))
|
||||
|
||||
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin"))
|
||||
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
|
||||
|
||||
mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull))
|
||||
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
||||
|
|
@ -161,16 +151,6 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
writeJSON(w, map[string]string{"build": buildID})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing"))
|
||||
|
||||
// Public endpoints — no JWT required.
|
||||
mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled)
|
||||
mux.HandleFunc("GET /api/sso/init", app.handleSSOInit)
|
||||
mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback)
|
||||
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
|
||||
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
|
||||
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)
|
||||
|
||||
// Kiosk — authenticated by volunteer token, no JWT required.
|
||||
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
||||
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
||||
|
|
@ -199,9 +179,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
}
|
||||
|
||||
func (app *App) bootstrapAdmin() error {
|
||||
adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL")
|
||||
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
|
||||
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
||||
if adminEmail == "" || adminPass == "" {
|
||||
if adminUser == "" || adminPass == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := app.countUsers()
|
||||
|
|
@ -212,11 +192,11 @@ func (app *App) bootstrapAdmin() error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{})
|
||||
_, err = app.createUser(adminUser, hash, "admin", []int{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Created admin user: %s", adminEmail)
|
||||
log.Printf("Created admin user: %s", adminUser)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -17,6 +16,7 @@ func testApp(t *testing.T) *App {
|
|||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
// Ensure config table exists (normally created by getOrCreateSecret)
|
||||
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
||||
return &App{
|
||||
db: db,
|
||||
|
|
@ -29,18 +29,17 @@ func testApp(t *testing.T) *App {
|
|||
func testAdminUser(t *testing.T, app *App) *User {
|
||||
t.Helper()
|
||||
hash, _ := hashPassword("admin123")
|
||||
u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{})
|
||||
u, err := app.createUser("admin", hash, "admin", []int{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func testUserWithRoles(t *testing.T, app *App, name string, roles []string, deptIDs []int) *User {
|
||||
func testUserWithRole(t *testing.T, app *App, username, role string, deptIDs []int) *User {
|
||||
t.Helper()
|
||||
email := strings.ToLower(name) + "@athens.example"
|
||||
hash, _ := hashPassword(name + "123")
|
||||
u, err := app.createUser(email, name, hash, roles, deptIDs)
|
||||
hash, _ := hashPassword(username + "123")
|
||||
u, err := app.createUser(username, hash, role, deptIDs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue