Compare commits
No commits in common. "trunk" and "0.1.0" have entirely different histories.
65 changed files with 1763 additions and 7583 deletions
27
Makefile
27
Makefile
|
|
@ -1,37 +1,16 @@
|
||||||
.PHONY: build frontend-build dev clean test patch minor major
|
.PHONY: build frontend-build dev clean
|
||||||
|
|
||||||
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
|
||||||
MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
|
|
||||||
MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
|
|
||||||
PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
|
|
||||||
|
|
||||||
build: frontend-build
|
build: frontend-build
|
||||||
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
CGO_ENABLED=0 go build -o turnpike .
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
cd frontend && npm ci && BUILD_ID=$$(git rev-parse --short HEAD) npm run build
|
cd frontend && npm ci && npm run build
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@echo "Run in two terminals:"
|
@echo "Run in two terminals:"
|
||||||
@echo " Terminal 1: go run . --db dev.db"
|
@echo " Terminal 1: go run . --db dev.db"
|
||||||
@echo " Terminal 2: cd frontend && npm run dev"
|
@echo " Terminal 2: cd frontend && npm run dev"
|
||||||
|
|
||||||
test:
|
|
||||||
go test ./...
|
|
||||||
cd frontend && npx vitest run
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f turnpike dev.db
|
rm -f turnpike dev.db
|
||||||
rm -rf frontend/dist
|
rm -rf frontend/dist
|
||||||
|
|
||||||
patch:
|
|
||||||
git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
|
||||||
@echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
|
||||||
|
|
||||||
minor:
|
|
||||||
git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
|
||||||
@echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
|
||||||
|
|
||||||
major:
|
|
||||||
git tag $(shell echo $$(($(MAJOR)+1))).0.0
|
|
||||||
@echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
|
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -1,21 +1,20 @@
|
||||||
# Turnpike
|
# 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.
|
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
|
## Features
|
||||||
|
|
||||||
- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
|
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
||||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
|
||||||
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
|
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
||||||
- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling
|
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
||||||
- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers
|
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
|
||||||
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
||||||
- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
|
|
||||||
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
||||||
- **Real-time** — check-ins and changes broadcast live via SSE
|
- **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
|
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
@ -60,11 +59,10 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
|
||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts |
|
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
||||||
| `ticketing` | Participants, tickets, import. No user management |
|
| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings |
|
||||||
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
|
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
||||||
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
|
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
||||||
| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages |
|
|
||||||
|
|
||||||
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
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
|
## 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
|
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
40
auth.go
40
auth.go
|
|
@ -12,9 +12,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
ParticipantID int `json:"pid"`
|
UserID int `json:"uid"`
|
||||||
Email string `json:"sub"`
|
Username string `json:"sub"`
|
||||||
Roles []string `json:"roles"`
|
Role string `json:"role"`
|
||||||
DeptIDs []int `json:"dept_ids,omitempty"`
|
DeptIDs []int `json:"dept_ids,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool {
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
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
|
expiry := time.Duration(app.tokenExpiry) * time.Hour
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
ParticipantID: s.ID,
|
UserID: u.ID,
|
||||||
Email: s.Email,
|
Username: u.Username,
|
||||||
Roles: s.Roles,
|
Role: u.Role,
|
||||||
DeptIDs: s.DepartmentIDs,
|
DeptIDs: u.DepartmentIDs,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
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)
|
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
|
if len(roles) > 0 && !hasRole(claims.Role, roles) {
|
||||||
writeError(w, "forbidden", http.StatusForbidden)
|
writeError(w, "forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -97,25 +97,9 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasAnyRole(roles []string, allowed []string) bool {
|
func hasRole(role string, allowed []string) bool {
|
||||||
for _, r := range roles {
|
for _, r := range allowed {
|
||||||
for _, a := range allowed {
|
if r == role {
|
||||||
if r == a {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCoLeadOnly(claims *Claims) bool {
|
|
||||||
return hasAnyRole(claims.Roles, []string{"colead"}) &&
|
|
||||||
!hasAnyRole(claims.Roles, []string{"admin", "staffing"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func inSlice(v int, s []int) bool {
|
|
||||||
for _, x := range s {
|
|
||||||
if x == v {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
auth_test.go
126
auth_test.go
|
|
@ -1,126 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoginValid(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
|
||||||
"email": admin.Email,
|
|
||||||
"password": "admin123",
|
|
||||||
})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["token"] == nil || result["token"] == "" {
|
|
||||||
t.Error("missing token in response")
|
|
||||||
}
|
|
||||||
user, ok := result["user"].(map[string]any)
|
|
||||||
if !ok || user["email"] != "oberon@athens.example" {
|
|
||||||
t.Errorf("user = %v", result["user"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginWrongPassword(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
testAdminUser(t, app)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
|
||||||
"email": "oberon@athens.example",
|
|
||||||
"password": "wrong",
|
|
||||||
})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginNonexistentUser(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testRequest("POST", "/api/login", map[string]string{
|
|
||||||
"email": "nobody@test.com",
|
|
||||||
"password": "test",
|
|
||||||
})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthMiddlewareNoToken(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testRequest("GET", "/api/me", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthMiddlewareInvalidToken(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/me", nil, "bad-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("status = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
|
|
||||||
token := testToken(t, app, gate)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/users", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("status = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMeEndpoint(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/me", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["email"] != "oberon@athens.example" {
|
|
||||||
t.Errorf("email = %v", result["email"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
186
db_test.go
186
db_test.go
|
|
@ -1,186 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMigrate(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
// Verify tables exist by querying each one
|
|
||||||
tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
|
||||||
for _, table := range tables {
|
|
||||||
var count int
|
|
||||||
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("table %s: %v", table, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateToken(t *testing.T) {
|
|
||||||
token, err := generateToken()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(token) != 8 {
|
|
||||||
t.Errorf("token length = %d, want 8", len(token))
|
|
||||||
}
|
|
||||||
for _, c := range token {
|
|
||||||
if !isValidTokenChar(c) {
|
|
||||||
t.Errorf("invalid char %c in token %s", c, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidTokenChar(c rune) bool {
|
|
||||||
for _, tc := range tokenChars {
|
|
||||||
if c == tc {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateUniqueToken(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
token, err := app.generateUniqueToken()
|
|
||||||
if err != nil || len(token) != 8 {
|
|
||||||
t.Fatalf("token=%q, err=%v", token, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDepartmentsCRUD(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
d, err := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.Name != "Gate" {
|
|
||||||
t.Errorf("name = %q", d.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
depts, _ := app.listDepartments("")
|
|
||||||
if len(depts) != 1 {
|
|
||||||
t.Errorf("list: got %d", len(depts))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.deleteDepartment(d.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShiftsCRUD(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
s, err := app.createShift(Shift{
|
|
||||||
DepartmentID: dept.ID,
|
|
||||||
Name: "Morning",
|
|
||||||
Day: "2026-03-15",
|
|
||||||
StartTime: "08:00",
|
|
||||||
EndTime: "12:00",
|
|
||||||
Capacity: 5,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if s.Name != "Morning" || s.Capacity != 5 {
|
|
||||||
t.Errorf("create: %+v", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ := app.getShift(s.ID)
|
|
||||||
if got == nil || got.Day != "2026-03-15" {
|
|
||||||
t.Error("get: not found or wrong day")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.deleteShift(s.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssignAndUnassignShift(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
|
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
if err := app.assignShift(v.ID, s.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
count, _ := app.shiftAssignedCount(s.ID)
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("assigned count = %d, want 1", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.unassignShift(v.ID, s.ID); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
count, _ = app.shiftAssignedCount(s.ID)
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("after unassign: count = %d, want 0", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckShiftConflict(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"})
|
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
|
||||||
s3, _ := app.createShift(Shift{DepartmentID: deptID, Name: "NoOverlap", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00"})
|
|
||||||
|
|
||||||
app.assignShift(v.ID, s1.ID)
|
|
||||||
|
|
||||||
// s2 overlaps s1 (10:00-14:00 vs 08:00-12:00)
|
|
||||||
conflicts, err := app.checkShiftConflict(v.ID, s2.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(conflicts) != 1 {
|
|
||||||
t.Errorf("overlap: got %d conflicts, want 1", len(conflicts))
|
|
||||||
}
|
|
||||||
|
|
||||||
// s3 does not overlap s1 (14:00-18:00 vs 08:00-12:00)
|
|
||||||
conflicts, _ = app.checkShiftConflict(v.ID, s3.ID)
|
|
||||||
if len(conflicts) != 0 {
|
|
||||||
t.Errorf("no overlap: got %d conflicts, want 0", len(conflicts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckShiftConflictMidnight(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Sound"})
|
|
||||||
deptID := dept.ID
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"})
|
|
||||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
// Night shift: 22:00-02:00 (spans midnight)
|
|
||||||
night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"})
|
|
||||||
// Late shift: 23:00-03:00 (overlaps with night)
|
|
||||||
late, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Late", Day: "2026-03-15", StartTime: "23:00", EndTime: "03:00"})
|
|
||||||
// Morning shift: 08:00-12:00 (no overlap with night)
|
|
||||||
morning, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
|
|
||||||
app.assignShift(v.ID, night.ID)
|
|
||||||
|
|
||||||
// Late should conflict with night
|
|
||||||
conflicts, _ := app.checkShiftConflict(v.ID, late.ID)
|
|
||||||
if len(conflicts) != 1 {
|
|
||||||
t.Errorf("midnight overlap: got %d conflicts, want 1", len(conflicts))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Morning should not conflict with night
|
|
||||||
conflicts, _ = app.checkShiftConflict(v.ID, morning.ID)
|
|
||||||
if len(conflicts) != 0 {
|
|
||||||
t.Errorf("no midnight overlap: got %d conflicts, want 0", len(conflicts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -105,27 +105,23 @@ docker run -p 8180:8180 \
|
||||||
|
|
||||||
## NixOS
|
## 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
|
```nix
|
||||||
frontendDist = pkgs.buildNpmPackage {
|
|
||||||
pname = "turnpike-frontend";
|
|
||||||
src = "${src}/frontend";
|
|
||||||
npmDepsHash = "sha256-...";
|
|
||||||
buildPhase = "npm run build";
|
|
||||||
installPhase = "cp -r dist $out";
|
|
||||||
};
|
|
||||||
|
|
||||||
turnpike = pkgs.buildGoModule {
|
turnpike = pkgs.buildGoModule {
|
||||||
pname = "turnpike";
|
pname = "turnpike";
|
||||||
src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
|
version = "0.1.0";
|
||||||
vendorHash = "sha256-...";
|
src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
|
||||||
|
vendorHash = null;
|
||||||
env.CGO_ENABLED = 0;
|
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
|
## 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 |
|
| Role | What they see | What they can do |
|
||||||
|------|--------------|------------------|
|
|------|--------------|------------------|
|
||||||
| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers |
|
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
||||||
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
|
| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
||||||
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
|
| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
||||||
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
|
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
||||||
| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages |
|
|
||||||
|
|
||||||
Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
|
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
|
## 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).
|
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
||||||
3. **Import participants** — see next section.
|
3. **Import attendees** — see next section.
|
||||||
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
|
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:
|
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 |
|
| Column | Maps to |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `Patron Name` | Ticket name |
|
| `Patron Name` | Name |
|
||||||
| `Patron Email` | Email |
|
| `Patron Email` | Email |
|
||||||
| `Order Number` | Ticket ID |
|
| `Order Number` | Ticket ID |
|
||||||
| `Tier Name` | Ticket type |
|
| `Tier Name` | Ticket type |
|
||||||
|
|
@ -44,7 +45,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||||
|
|
||||||
| Column | Maps to |
|
| Column | Maps to |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `name` (required) | Ticket name |
|
| `name` (required) | Name |
|
||||||
| `email` | Email |
|
| `email` | Email |
|
||||||
| `ticket_id` | Ticket ID |
|
| `ticket_id` | Ticket ID |
|
||||||
| `ticket_type` | Ticket type |
|
| `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.
|
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.
|
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
|
||||||
|
|
||||||
### Signup flow
|
|
||||||
|
|
||||||
1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
|
|
||||||
2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record.
|
|
||||||
3. A confirmation email is sent with a unique link (`/confirm/{token}`).
|
|
||||||
4. The volunteer clicks the link to confirm their email.
|
|
||||||
5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
|
|
||||||
|
|
||||||
Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration.
|
|
||||||
|
|
||||||
### Configuring the signup form
|
|
||||||
|
|
||||||
In **Settings**, the "Volunteer Signup" card controls:
|
|
||||||
|
|
||||||
- **Note field label** — customize the label shown on the form (default: "Additional note")
|
|
||||||
- **Note field required** — when checked, volunteers must fill in the note to submit
|
|
||||||
|
|
||||||
### Opening shift signups
|
|
||||||
|
|
||||||
In **Settings**, the "Shift Signups" card has an open/close toggle:
|
|
||||||
|
|
||||||
- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
|
|
||||||
- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
|
|
||||||
|
|
||||||
If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
|
|
||||||
|
|
||||||
## Managing Volunteers
|
## Managing Volunteers
|
||||||
|
|
||||||
Under **Volunteers**, you can:
|
Under **Volunteers**, you can:
|
||||||
|
|
||||||
- Create volunteers manually (name, email, department, co-lead, note)
|
- Create volunteers manually (name, email, department)
|
||||||
- Edit existing volunteers (department, co-lead, note) via the inline Edit button
|
- Link a volunteer to an existing attendee record (for dual check-in at the gate)
|
||||||
- Confirm registered volunteers (admin, staffing, colead)
|
- Assign volunteers to departments
|
||||||
- Mark volunteers as ready (briefed at the volunteer station)
|
- Check in volunteers
|
||||||
|
|
||||||
### Volunteer statuses
|
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.
|
||||||
|
|
||||||
| Status | Meaning | Who sets it |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
|
|
||||||
| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
|
|
||||||
| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead |
|
|
||||||
| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in |
|
|
||||||
|
|
||||||
**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them.
|
|
||||||
|
|
||||||
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
|
||||||
|
|
||||||
## Shift Scheduling
|
## Shift Scheduling
|
||||||
|
|
||||||
Under **Schedule**, create shifts for each department:
|
Under **Shifts**, create shifts for each department:
|
||||||
|
|
||||||
- **Day** — the date of the shift
|
- **Day** — the date of the shift
|
||||||
- **Start/end time** — HH:MM format
|
- **Start/end time** — HH:MM format
|
||||||
|
|
@ -120,29 +86,27 @@ Under **Schedule**, create shifts for each department:
|
||||||
|
|
||||||
### Assigning volunteers
|
### 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
|
### 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
|
## 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
|
### Setup
|
||||||
|
|
||||||
Kiosk links are generated and distributed automatically through the volunteer signup flow:
|
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:
|
||||||
1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
|
- **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
|
||||||
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.
|
- **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
|
||||||
3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately.
|
3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
|
||||||
|
|
||||||
**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL.
|
|
||||||
|
|
||||||
### Volunteer experience
|
### 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
|
- Their name and department
|
||||||
- Currently assigned shifts
|
- 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.
|
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.
|
- **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.
|
- **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.
|
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
|
- Shifts grouped by department and day
|
||||||
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
||||||
- Conflict badges when a volunteer has overlapping shifts on the same day
|
- 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:
|
Actions available:
|
||||||
- Create new shifts (+ Add shift button)
|
|
||||||
- Edit shift details inline
|
|
||||||
- Delete shifts
|
|
||||||
- Assign volunteers to shifts from a dropdown
|
- Assign volunteers to shifts from a dropdown
|
||||||
- Remove volunteer assignments
|
- Remove volunteer assignments
|
||||||
- Reorder shifts within a department
|
- Reorder shifts within a department
|
||||||
|
- Edit shift details inline
|
||||||
|
|
||||||
## SMTP Configuration
|
## 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 |
|
| 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.
|
- **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.
|
- **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.
|
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
|
||||||
|
|
||||||
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
|
- **Attendee export** — all attendee records with check-in status
|
||||||
- **Ticket export** — all ticket records with codes and 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))
|
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
|
baseURL := app.baseURL
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&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()
|
event, _ := app.getEvent()
|
||||||
|
eventName := "the event"
|
||||||
if event != nil && event.Name != "" {
|
if event != nil && event.Name != "" {
|
||||||
return event.Name
|
eventName = 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := app.loadSMTPConfig()
|
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||||
eventName := app.eventName()
|
|
||||||
link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code)
|
|
||||||
name := p.PreferredName
|
|
||||||
if name == "" {
|
|
||||||
name = tk.Name
|
|
||||||
}
|
|
||||||
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
||||||
body := fmt.Sprintf(
|
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",
|
"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)
|
return sendEmail(cfg, a.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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
|
@ -8,7 +8,6 @@ pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.vite
|
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,43 @@
|
||||||
# Turnpike Frontend
|
# Svelte + Vite
|
||||||
|
|
||||||
Svelte 5 + Vite PWA. Offline-first with Dexie (IndexedDB) and background sync.
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
## Development
|
## Recommended IDE Setup
|
||||||
|
|
||||||
From the repo root with `direnv allow` (or Node.js 18+ installed):
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
```sh
|
## Need an official Svelte framework?
|
||||||
cd frontend
|
|
||||||
npm install
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
npm run dev
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `checkJs` in the JS template?**
|
||||||
|
|
||||||
|
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// store.js
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs on `:5173`, proxies `/api` to the Go backend on `:8180`.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Output goes to `dist/`, which the Go binary embeds at compile time.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- `src/db.js` — Dexie schema, session management
|
|
||||||
- `src/api.js` — all API calls, injects `Authorization: Bearer` header
|
|
||||||
- `src/sync.js` — sync pull, SSE stream, outbox flush
|
|
||||||
- `src/pages/` — page components (one per route)
|
|
||||||
- `src/components/` — shared UI components
|
|
||||||
- `src/app.css` — global CSS custom properties (colors, spacing, type scale)
|
|
||||||
|
|
||||||
All UI reads come from Dexie via `liveQuery()`, not direct API calls. Styles are scoped per component; no hardcoded color values.
|
|
||||||
|
|
|
||||||
977
frontend/package-lock.json
generated
977
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,20 +6,14 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"fake-indexeddb": "^6.2.5",
|
|
||||||
"jsdom": "^28.1.0",
|
|
||||||
"svelte": "^5.45.2",
|
"svelte": "^5.45.2",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dexie": "^4.3.0",
|
"dexie": "^4.3.0"
|
||||||
"lucide-svelte": "^0.576.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +1,90 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
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 { syncPull, startSSE, startSyncLoop } from './sync.js'
|
||||||
import Login from './pages/Login.svelte'
|
import Login from './pages/Login.svelte'
|
||||||
import Dashboard from './pages/Dashboard.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 Volunteers from './pages/Volunteers.svelte'
|
||||||
import Departments from './pages/Departments.svelte'
|
import Departments from './pages/Departments.svelte'
|
||||||
|
import Shifts from './pages/Shifts.svelte'
|
||||||
import Users from './pages/Users.svelte'
|
import Users from './pages/Users.svelte'
|
||||||
import Import from './pages/Import.svelte'
|
import Import from './pages/Import.svelte'
|
||||||
import VolunteerKiosk from './pages/VolunteerKiosk.svelte'
|
import Kiosk from './pages/Kiosk.svelte'
|
||||||
import VolunteerSignup from './pages/VolunteerSignup.svelte'
|
import GateUI from './pages/GateUI.svelte'
|
||||||
import ConfirmEmail from './pages/ConfirmEmail.svelte'
|
|
||||||
import GateKiosk from './pages/GateKiosk.svelte'
|
|
||||||
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||||
import Settings from './pages/Settings.svelte'
|
import Settings from './pages/Settings.svelte'
|
||||||
import Nav from './components/Nav.svelte'
|
import Nav from './components/Nav.svelte'
|
||||||
import SyncStatus from './components/SyncStatus.svelte'
|
import SyncStatus from './components/SyncStatus.svelte'
|
||||||
|
|
||||||
const clientBuild = __BUILD_ID__
|
|
||||||
|
|
||||||
let session = $state(null)
|
let session = $state(null)
|
||||||
let loading = $state(true)
|
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)
|
// Check if this is a kiosk token URL before doing anything else
|
||||||
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
const kioskToken = $derived(window.location.hash.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkVersion() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/version')
|
|
||||||
const { build } = await res.json()
|
|
||||||
if (build && build !== clientBuild) updateAvailable = true
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
checkVersion()
|
// Kiosk pages don't need auth
|
||||||
|
if (kioskToken) {
|
||||||
// Public pages don't need auth
|
|
||||||
if (isPublicPage) {
|
|
||||||
loading = false
|
loading = false
|
||||||
return
|
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()
|
session = await getSession()
|
||||||
}
|
|
||||||
loading = false
|
loading = false
|
||||||
if (session) {
|
if (session) {
|
||||||
await syncPull()
|
await syncPull()
|
||||||
startSSE()
|
startSSE()
|
||||||
startSyncLoop()
|
startSyncLoop()
|
||||||
}
|
}
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
route = window.location.pathname
|
route = window.location.hash || '#/'
|
||||||
mobileNavOpen = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Periodically check for updates
|
|
||||||
setInterval(checkVersion, 60000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function onLogin(s) {
|
function onLogin(s) {
|
||||||
session = s
|
session = s
|
||||||
navigate('/')
|
window.location.hash = '#/'
|
||||||
syncPull().then(() => { startSSE(); startSyncLoop() })
|
syncPull().then(() => { startSSE(); startSyncLoop() })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
await clearSession()
|
await clearSession()
|
||||||
session = null
|
session = null
|
||||||
navigate('/login')
|
window.location.hash = '#/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = $derived(route || '/')
|
const path = $derived(route.replace(/^#/, '') || '/')
|
||||||
const roles = $derived(session?.user?.roles ?? [])
|
const role = $derived(session?.user?.role ?? '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if updateAvailable}
|
|
||||||
<div class="update-banner">
|
|
||||||
A new version is available.
|
|
||||||
<button onclick={() => location.reload()}>Refresh</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<!-- checking session -->
|
<!-- checking session -->
|
||||||
{:else if kioskToken}
|
{:else if kioskToken}
|
||||||
<VolunteerKiosk />
|
<Kiosk />
|
||||||
{:else if isVolunteerSignup}
|
|
||||||
<VolunteerSignup />
|
|
||||||
{:else if isConfirmEmail}
|
|
||||||
<ConfirmEmail />
|
|
||||||
{:else if !session}
|
{:else if !session}
|
||||||
<Login onlogin={onLogin} error={ssoError} />
|
<Login onlogin={onLogin} />
|
||||||
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
{:else if role === 'gate'}
|
||||||
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
||||||
<GateKiosk {session} {onLogout} />
|
<GateUI {session} {onLogout} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<Nav {session} {onLogout} active={path} />
|
||||||
{#if mobileNavOpen}
|
|
||||||
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
|
|
||||||
{/if}
|
|
||||||
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<header class="mobile-header">
|
|
||||||
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</button>
|
|
||||||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
|
||||||
</header>
|
|
||||||
{#if path === '/' || path === ''}
|
{#if path === '/' || path === ''}
|
||||||
{#if roles.length === 1 && roles[0] === 'colead'}
|
{#if role === 'volunteer_lead'}
|
||||||
<ScheduleBoard {session} />
|
<ScheduleBoard {session} />
|
||||||
{:else}
|
{:else}
|
||||||
<Dashboard {session} {navigate} />
|
<Dashboard {session} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if path.startsWith('/participants')}
|
{:else if path.startsWith('/attendees')}
|
||||||
<Participants {session} />
|
<Attendees {session} />
|
||||||
{:else if path.startsWith('/volunteers')}
|
{:else if path.startsWith('/volunteers')}
|
||||||
<Volunteers {session} />
|
<Volunteers {session} />
|
||||||
{:else if path.startsWith('/departments')}
|
{:else if path.startsWith('/departments')}
|
||||||
<Departments {session} />
|
<Departments {session} />
|
||||||
|
{:else if path.startsWith('/shifts')}
|
||||||
|
<Shifts {session} />
|
||||||
{:else if path.startsWith('/schedule')}
|
{:else if path.startsWith('/schedule')}
|
||||||
<ScheduleBoard {session} />
|
<ScheduleBoard {session} />
|
||||||
{:else if path.startsWith('/users')}
|
{:else if path.startsWith('/users')}
|
||||||
|
|
@ -174,33 +100,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
.update-banner {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--c-accent);
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-banner button {
|
|
||||||
margin-left: 12px;
|
|
||||||
padding: 2px 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.5);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-banner button:hover {
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { db, clearSession } from './db.js'
|
import { db } from './db.js'
|
||||||
|
|
||||||
async function getToken() {
|
async function getToken() {
|
||||||
const session = await db.session.get(1)
|
const session = await db.session.get(1)
|
||||||
|
|
@ -17,8 +17,8 @@ export async function apiFetch(path, options = {}) {
|
||||||
|
|
||||||
const res = await fetch(path, { ...options, headers })
|
const res = await fetch(path, { ...options, headers })
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
await clearSession()
|
await db.session.clear()
|
||||||
window.location.pathname = '/login'
|
window.location.hash = '#/login'
|
||||||
throw new Error('unauthorized')
|
throw new Error('unauthorized')
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
|
|
@ -48,29 +48,28 @@ async function kioskFetch(path, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (email, password) =>
|
login: (username, password) =>
|
||||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||||
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||||
me: () => apiJSON('/api/me'),
|
me: () => apiJSON('/api/me'),
|
||||||
event: {
|
event: {
|
||||||
get: () => apiJSON('/api/event'),
|
get: () => apiJSON('/api/event'),
|
||||||
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
|
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
},
|
},
|
||||||
participants: {
|
attendees: {
|
||||||
list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)),
|
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
|
||||||
get: (id) => apiJSON(`/api/participants/${id}`),
|
get: (id) => apiJSON(`/api/attendees/${id}`),
|
||||||
create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }),
|
create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }),
|
delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }),
|
||||||
merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }),
|
checkIn: (id, opts = {}) =>
|
||||||
},
|
apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }),
|
||||||
tickets: {
|
generateTokens: () =>
|
||||||
list: () => apiJSON('/api/tickets'),
|
apiJSON('/api/attendees/generate-tokens', { method: 'POST' }),
|
||||||
create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }),
|
emailToken: (id) =>
|
||||||
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
|
apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }),
|
||||||
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
emailAllTokens: () =>
|
||||||
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
apiJSON('/api/attendees/email-tokens', { method: 'POST' }),
|
||||||
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
|
|
||||||
},
|
},
|
||||||
volunteers: {
|
volunteers: {
|
||||||
list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)),
|
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) }),
|
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', 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' }),
|
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||||
markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }),
|
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
||||||
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }),
|
|
||||||
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
|
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' }),
|
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
|
|
@ -111,21 +109,6 @@ export const api = {
|
||||||
get: () => apiJSON('/api/settings'),
|
get: () => apiJSON('/api/settings'),
|
||||||
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
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) => {
|
import: async (formData) => {
|
||||||
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { db, saveSession, clearSession } from './db.js'
|
|
||||||
|
|
||||||
// Must import api after fake-indexeddb is initialized (via test-setup.js)
|
|
||||||
const { apiFetch, apiJSON, api } = await import('./api.js')
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await Promise.all(db.tables.map(t => t.clear()))
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
function mockFetch(body = {}, status = 200) {
|
|
||||||
const fn = vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
statusText: 'OK',
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(body)),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
globalThis.fetch = fn
|
|
||||||
return fn
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('apiFetch', () => {
|
|
||||||
it('adds Authorization header when session exists', async () => {
|
|
||||||
await saveSession('mytoken', { id: 1 })
|
|
||||||
const f = mockFetch()
|
|
||||||
await apiFetch('/api/test')
|
|
||||||
expect(f).toHaveBeenCalledTimes(1)
|
|
||||||
const [, opts] = f.mock.calls[0]
|
|
||||||
expect(opts.headers['Authorization']).toBe('Bearer mytoken')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits Authorization when no session', async () => {
|
|
||||||
const f = mockFetch()
|
|
||||||
await apiFetch('/api/test')
|
|
||||||
const [, opts] = f.mock.calls[0]
|
|
||||||
expect(opts.headers['Authorization']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clears session on 401', async () => {
|
|
||||||
await saveSession('expired', { id: 1 })
|
|
||||||
mockFetch({}, 401)
|
|
||||||
await expect(apiFetch('/api/test')).rejects.toThrow('unauthorized')
|
|
||||||
expect(await db.session.get(1)).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('apiJSON', () => {
|
|
||||||
it('parses JSON response', async () => {
|
|
||||||
mockFetch({ name: 'Titania' })
|
|
||||||
const result = await apiJSON('/api/test')
|
|
||||||
expect(result.name).toBe('Titania')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws on non-OK response', async () => {
|
|
||||||
mockFetch({ error: 'not found' }, 404)
|
|
||||||
await expect(apiJSON('/api/test')).rejects.toThrow('not found')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('api methods', () => {
|
|
||||||
it('login calls correct endpoint', async () => {
|
|
||||||
const f = mockFetch({ token: 'tok', user: { id: 1 } })
|
|
||||||
await api.login('admin@example.com', 'pass')
|
|
||||||
const [url, opts] = f.mock.calls[0]
|
|
||||||
expect(url).toBe('/api/login')
|
|
||||||
expect(opts.method).toBe('POST')
|
|
||||||
expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('participants.list calls correct endpoint', async () => {
|
|
||||||
const f = mockFetch({ participants: [] })
|
|
||||||
await api.participants.list({ search: 'test' })
|
|
||||||
expect(f.mock.calls[0][0]).toBe('/api/participants?search=test')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('participants.delete uses DELETE method', async () => {
|
|
||||||
const f = mockFetch({}, 204)
|
|
||||||
await api.participants.delete(5)
|
|
||||||
expect(f.mock.calls[0][0]).toBe('/api/participants/5')
|
|
||||||
expect(f.mock.calls[0][1].method).toBe('DELETE')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sync.pull passes since param', async () => {
|
|
||||||
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
|
|
||||||
await api.sync.pull('2026-01-01T00:00:00Z')
|
|
||||||
expect(f.mock.calls[0][0]).toContain('since=')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sync.pull omits since when empty', async () => {
|
|
||||||
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
|
|
||||||
await api.sync.pull('')
|
|
||||||
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('signup methods', () => {
|
|
||||||
it('signup.config fetches config without auth', async () => {
|
|
||||||
const f = mockFetch({ departments: [], volunteer_note_label: 'Note' })
|
|
||||||
await api.signup.config()
|
|
||||||
const [url, opts] = f.mock.calls[0]
|
|
||||||
expect(url).toBe('/api/public/signup-config')
|
|
||||||
expect(opts.headers['Authorization']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('signup.submit posts form data without auth', async () => {
|
|
||||||
const f = mockFetch({ ok: true })
|
|
||||||
await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' })
|
|
||||||
const [url, opts] = f.mock.calls[0]
|
|
||||||
expect(url).toBe('/api/public/signup')
|
|
||||||
expect(opts.method).toBe('POST')
|
|
||||||
expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' })
|
|
||||||
expect(opts.headers['Authorization']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('signup.confirm posts token without auth', async () => {
|
|
||||||
const f = mockFetch({ status: 'confirmed' })
|
|
||||||
await api.signup.confirm('abc123')
|
|
||||||
const [url, opts] = f.mock.calls[0]
|
|
||||||
expect(url).toBe('/api/public/confirm')
|
|
||||||
expect(opts.method).toBe('POST')
|
|
||||||
expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' })
|
|
||||||
expect(opts.headers['Authorization']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('signup.submit throws on 400', async () => {
|
|
||||||
mockFetch({ error: 'preferred name and email are required' }, 400)
|
|
||||||
await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('settings shift signups', () => {
|
|
||||||
it('toggleShiftSignups posts open flag', async () => {
|
|
||||||
await saveSession('tok', { id: 1 })
|
|
||||||
const f = mockFetch({ shift_signups_open: true })
|
|
||||||
await api.settings.toggleShiftSignups(true)
|
|
||||||
const [url, opts] = f.mock.calls[0]
|
|
||||||
expect(url).toBe('/api/settings/shift-signups')
|
|
||||||
expect(opts.method).toBe('POST')
|
|
||||||
expect(JSON.parse(opts.body)).toEqual({ open: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -66,9 +66,6 @@ a:hover { color: var(--c-accent-h); }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
.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 */
|
||||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
.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);
|
width: 100%; font-family: var(--font);
|
||||||
transition: border-color var(--transition);
|
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:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
||||||
input::placeholder { color: var(--c-muted); }
|
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 */
|
||||||
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
.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;
|
font-size: 0.72rem; font-weight: 600;
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
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-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-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-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); }
|
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||||
|
|
||||||
|
|
@ -184,68 +170,8 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
/* Mobile header — hidden on desktop */
|
|
||||||
.mobile-header { display: none; }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.mobile-header {
|
.sidebar { display: none; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--c-surface);
|
|
||||||
border-bottom: 1px solid var(--c-border);
|
|
||||||
}
|
|
||||||
.mobile-brand { font-weight: 700; font-size: 1rem; }
|
|
||||||
.mobile-brand .accent { color: var(--c-accent); }
|
|
||||||
.hamburger {
|
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
|
||||||
background: none; border: none; padding: 4px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.hamburger span {
|
|
||||||
display: block; width: 20px; height: 2px;
|
|
||||||
background: var(--c-text); border-radius: 1px;
|
|
||||||
transition: transform var(--transition), opacity var(--transition);
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 200ms ease;
|
|
||||||
}
|
|
||||||
.sidebar.open { transform: translateX(0); }
|
|
||||||
.nav-overlay {
|
|
||||||
position: fixed; inset: 0; z-index: 99;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
.page { padding: 1rem; }
|
.page { padding: 1rem; }
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
.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>
|
</script>
|
||||||
|
|
||||||
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||||
{loading ? '…' : 'Mark ready'}
|
{loading ? '…' : '✓ Check in'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,57 @@
|
||||||
<script>
|
<script>
|
||||||
import { LayoutDashboard, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
|
let { session, active, onLogout } = $props()
|
||||||
|
|
||||||
let { session, active, onLogout, navigate, open = false } = $props()
|
const role = $derived(session?.user?.role ?? '')
|
||||||
|
|
||||||
const roles = $derived(session?.user?.roles ?? [])
|
|
||||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
|
||||||
|
|
||||||
const iconProps = { size: 18, strokeWidth: 1.75 }
|
|
||||||
|
|
||||||
|
// Role-specific nav sets
|
||||||
const links = $derived.by(() => {
|
const links = $derived.by(() => {
|
||||||
if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
|
if (role === 'ticketing') return [
|
||||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
|
||||||
]
|
]
|
||||||
if (!hasRole('admin') && hasRole('staffing')) return [
|
if (role === 'volunteer_lead') return [
|
||||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '#/', label: 'Schedule', icon: '◷' },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
|
||||||
]
|
]
|
||||||
|
if (role === 'coordinator') return [
|
||||||
|
{ href: '#/', label: 'Dashboard', icon: '⊞' },
|
||||||
|
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
|
||||||
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
|
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
|
||||||
|
]
|
||||||
|
// admin — all links
|
||||||
return [
|
return [
|
||||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '#/', label: 'Dashboard', icon: '⊞' },
|
||||||
{ href: '/participants', label: 'Participants', icon: Ticket },
|
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
|
||||||
{ href: '/import', label: 'Import', icon: Upload },
|
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
|
||||||
{ href: '/users', label: 'Users', icon: Users },
|
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '#/users', label: 'Users', icon: '⊕' },
|
||||||
|
{ href: '#/settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
function isActive(href) {
|
function isActive(href) {
|
||||||
if (href === '/') return active === '/' || active === ''
|
const p = href.replace(/^#/, '')
|
||||||
return active.startsWith(href)
|
if (p === '/') return active === '/' || active === ''
|
||||||
|
return active.startsWith(p)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" class:open>
|
<nav class="sidebar">
|
||||||
<div class="sidebar-brand">Turn<span>pike</span></div>
|
<div class="sidebar-brand">Turn<span>pike</span></div>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a href={link.href} class="nav-link" class:active={isActive(link.href)}
|
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
|
||||||
onclick={(e) => { e.preventDefault(); navigate(link.href) }}>
|
<span class="icon">{link.icon}</span>
|
||||||
<link.icon {...iconProps} />
|
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<button class="nav-link btn-ghost" style="border:none;cursor:pointer;width:100%;text-align:left" onclick={onLogout}>
|
<button class="nav-link btn-ghost" style="border:none;cursor:pointer;width:100%;text-align:left" onclick={onLogout}>
|
||||||
<LogOut {...iconProps} /> Sign out
|
<span class="icon">→</span> Sign out
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@
|
||||||
<div class="sync-bar">
|
<div class="sync-bar">
|
||||||
<div class="sync-dot {dotClass}"></div>
|
<div class="sync-dot {dotClass}"></div>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span class="text-muted" style="font-size:0.72rem">{__BUILD_ID__}</span>
|
|
||||||
<span class="text-muted" style="margin-left:auto;font-size:0.72rem">Last sync: {lastSyncLabel}</span>
|
<span class="text-muted" style="margin-left:auto;font-size:0.72rem">Last sync: {lastSyncLabel}</span>
|
||||||
{#if online && !syncing}
|
{#if online && !syncing}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={manualSync}>↻</button>
|
<button class="btn btn-ghost btn-sm" onclick={manualSync}>↻</button>
|
||||||
|
|
|
||||||
|
|
@ -26,42 +26,6 @@ db.version(2).stores({
|
||||||
outbox: '++id, table, op, synced_at',
|
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() {
|
export async function getLastSync() {
|
||||||
const m = await db.meta.get('last_sync')
|
const m = await db.meta.get('last_sync')
|
||||||
return m?.value ?? ''
|
return m?.value ?? ''
|
||||||
|
|
@ -80,18 +44,6 @@ export async function saveSession(token, user) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearSession() {
|
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.session.clear()
|
||||||
await db.meta.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()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
|
||||||
import { db, getLastSync, setLastSync, getSession, saveSession, clearSession } from './db.js'
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await Promise.all(db.tables.map(t => t.clear()))
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('db schema', () => {
|
|
||||||
it('has expected tables', () => {
|
|
||||||
const names = db.tables.map(t => t.name).sort()
|
|
||||||
expect(names).toEqual([
|
|
||||||
'departments', 'event', 'meta',
|
|
||||||
'participants', 'session', 'shifts', 'tickets', 'volunteer_shifts', 'volunteers',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('session', () => {
|
|
||||||
it('returns undefined when no session', async () => {
|
|
||||||
const s = await getSession()
|
|
||||||
expect(s).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saves and retrieves session', async () => {
|
|
||||||
await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] })
|
|
||||||
const s = await getSession()
|
|
||||||
expect(s.token).toBe('tok123')
|
|
||||||
expect(s.user.email).toBe('admin@example.com')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clears session and meta', async () => {
|
|
||||||
await saveSession('tok123', { id: 1 })
|
|
||||||
await setLastSync('2026-01-01T00:00:00Z')
|
|
||||||
await clearSession()
|
|
||||||
expect(await getSession()).toBeUndefined()
|
|
||||||
expect(await getLastSync()).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('lastSync', () => {
|
|
||||||
it('returns empty string when unset', async () => {
|
|
||||||
expect(await getLastSync()).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roundtrips a timestamp', async () => {
|
|
||||||
await setLastSync('2026-03-01T12:00:00Z')
|
|
||||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
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 { liveQuery } from 'dexie'
|
||||||
import { db } from '../db.js'
|
import { db } from '../db.js'
|
||||||
|
|
||||||
let { session, navigate } = $props()
|
let { session } = $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'))
|
|
||||||
|
|
||||||
|
const attendees = liveQuery(() => db.attendees.toArray())
|
||||||
const event = liveQuery(() => db.event.get(1))
|
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 total = $derived(($attendees ?? []).length)
|
||||||
const tickets = $derived($allTickets ?? [])
|
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
|
||||||
const ticketTotal = $derived(tickets.length)
|
const remaining = $derived(total - checkedIn)
|
||||||
const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length)
|
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
|
||||||
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)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -70,113 +28,35 @@
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Total</div>
|
<div class="stat-label">Total</div>
|
||||||
<div class="stat-value">{volTotal}</div>
|
<div class="stat-value">{total}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Checked in</div>
|
<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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Leads</div>
|
<div class="stat-label">Remaining</div>
|
||||||
<div class="stat-value">{volLeads}</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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Shift coverage (admin/staffing/colead) -->
|
<p class="text-muted" style="font-size:0.85rem">
|
||||||
{#if isStaffing || isColead}
|
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</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 editDesc = $state('')
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
|
||||||
const roles = $derived(session?.user?.roles ?? [])
|
const role = $derived(session?.user?.role ?? '')
|
||||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
const canCreate = $derived(['admin', 'coordinator'].includes(role))
|
||||||
const canCreate = $derived(hasRole('admin', 'staffing'))
|
const canDelete = $derived(role === 'admin')
|
||||||
const canDelete = $derived(hasRole('admin'))
|
|
||||||
|
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
db.departments.filter(d => !d.deleted_at).toArray()
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
|
|
@ -101,7 +100,7 @@
|
||||||
{#if showAdd && canCreate}
|
{#if showAdd && canCreate}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addDept}>
|
<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">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label for="d-name">Name *</label>
|
<label for="d-name">Name *</label>
|
||||||
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||||
|
|
@ -112,7 +111,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label for="d-color">Color</label>
|
<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>
|
</div>
|
||||||
<div class="actions" style="margin-top:1rem">
|
<div class="actions" style="margin-top:1rem">
|
||||||
|
|
@ -128,7 +127,7 @@
|
||||||
{#if ($allDepts ?? []).length === 0}
|
{#if ($allDepts ?? []).length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No departments yet</strong>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
|
|
@ -143,8 +142,8 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each $allDepts ?? [] as d (d.id)}
|
{#each $allDepts ?? [] as d (d.id)}
|
||||||
{#if editID === d.id}
|
{#if editID === d.id}
|
||||||
<tr class="edit-row">
|
<tr>
|
||||||
<td class="td-name">
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
<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 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" />
|
<input bind:value={editName} required placeholder="Name" style="margin:0" />
|
||||||
|
|
@ -154,7 +153,7 @@
|
||||||
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
||||||
</td>
|
</td>
|
||||||
{#if canCreate}
|
{#if canCreate}
|
||||||
<td class="td-actions">
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
|
|
@ -166,13 +165,13 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-name">
|
<td>
|
||||||
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
||||||
<strong>{d.name}</strong>
|
<strong>{d.name}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-desc text-muted">{d.description || '—'}</td>
|
<td class="text-muted">{d.description || '—'}</td>
|
||||||
{#if canCreate}
|
{#if canCreate}
|
||||||
<td class="td-actions">
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
||||||
{#if canDelete}
|
{#if canDelete}
|
||||||
|
|
@ -189,13 +188,3 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 { session, onLogout } = $props()
|
||||||
|
|
||||||
let search = $state('')
|
let search = $state('')
|
||||||
let manuallySelectedId = $state(null)
|
|
||||||
let showAll = $state(false)
|
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let scannerMsg = $state('')
|
let scannerMsg = $state('')
|
||||||
let qrSupported = $state(false)
|
let qrSupported = $state(false)
|
||||||
|
|
@ -18,89 +16,36 @@
|
||||||
let detector = $state(null)
|
let detector = $state(null)
|
||||||
let scanInterval = $state(null)
|
let scanInterval = $state(null)
|
||||||
|
|
||||||
const tickets = liveQuery(() =>
|
const attendees = liveQuery(() =>
|
||||||
db.tickets.filter(t => !t.deleted_at).toArray()
|
db.attendees.filter(a => !a.deleted_at).toArray()
|
||||||
)
|
|
||||||
|
|
||||||
const participants = liveQuery(() =>
|
|
||||||
db.participants.filter(p => !p.deleted_at).toArray()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const recentCheckIns = liveQuery(() =>
|
const recentCheckIns = liveQuery(() =>
|
||||||
db.tickets
|
db.attendees
|
||||||
.filter(t => !!t.checked_in_at && !t.deleted_at)
|
.filter(a => a.checked_in && !a.deleted_at)
|
||||||
.toArray()
|
.toArray()
|
||||||
.then(arr => arr
|
.then(arr => arr
|
||||||
|
.filter(a => a.checked_in_at)
|
||||||
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exact code/external_id match (QR scan or typed code)
|
const filtered = $derived.by(() => {
|
||||||
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 s = search.trim().toLowerCase()
|
const s = search.trim().toLowerCase()
|
||||||
if (!s || s.length < 2) return []
|
if (!s || s.length < 2) return []
|
||||||
const byTicketName = new Set(
|
return ($attendees ?? [])
|
||||||
($tickets ?? [])
|
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
|
||||||
.filter(t => t.name?.toLowerCase().includes(s))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.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 || ''))
|
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Manual selection takes priority; fall back to auto-select on single match
|
const selected = $derived.by(() => {
|
||||||
const selectedParticipant = $derived.by(() => {
|
if (filtered.length === 1) return filtered[0]
|
||||||
if (manuallySelectedId) {
|
const s = search.trim().toLowerCase()
|
||||||
return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null
|
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
|
||||||
}
|
|
||||||
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
|
||||||
return 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(() => {
|
onMount(() => {
|
||||||
qrSupported = 'BarcodeDetector' in window
|
qrSupported = 'BarcodeDetector' in window
|
||||||
})
|
})
|
||||||
|
|
@ -151,19 +96,40 @@
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkInTicket(ticket) {
|
async function checkIn(attendee, count = 1) {
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const result = await api.tickets.checkIn(ticket.id)
|
const result = await api.attendees.checkIn(attendee.id, { count })
|
||||||
if (result.ticket) {
|
if (result.attendee) {
|
||||||
await db.tickets.put(result.ticket)
|
await db.attendees.put(result.attendee)
|
||||||
search = ''
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
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) {
|
function fmt(ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
@ -189,9 +155,6 @@
|
||||||
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="gbtn gbtn-ghost" onclick={() => { showAll = !showAll; search = ''; manuallySelectedId = null }}>
|
|
||||||
{showAll ? '✕ Close' : '☰ Browse'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if scanning}
|
{#if scanning}
|
||||||
|
|
@ -209,122 +172,74 @@
|
||||||
<div class="gate-msg gate-msg-error">{error}</div>
|
<div class="gate-msg gate-msg-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Exact code/ID match card -->
|
<!-- Matched attendee card -->
|
||||||
{#if matchedTicket}
|
{#if selected}
|
||||||
{@const p = participantFor(matchedTicket)}
|
{@const rem = remaining(selected)}
|
||||||
|
{@const prog = progressLabel(selected)}
|
||||||
<div class="gate-match">
|
<div class="gate-match">
|
||||||
<div class="gate-match-name">{nameFor(matchedTicket)}</div>
|
<div class="gate-match-name">{selected.name}</div>
|
||||||
{#if matchedTicket.ticket_type}
|
{#if selected.ticket_type}
|
||||||
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
<div class="gate-match-sub">{selected.ticket_type}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if matchedTicket.external_id}
|
{#if selected.ticket_id}
|
||||||
<div class="gate-match-sub text-muted">#{matchedTicket.external_id}</div>
|
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if p?.email}
|
{#if prog}
|
||||||
<div class="gate-match-sub text-muted">{p.email}</div>
|
<div class="gate-party">
|
||||||
|
<span class="gate-party-label">{prog}</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="gate-match-actions">
|
<div class="gate-match-actions">
|
||||||
{#if !matchedTicket.checked_in_at}
|
{#if rem > 0}
|
||||||
<button class="gbtn gbtn-success" onclick={() => checkInTicket(matchedTicket)}>
|
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
|
||||||
✓ Check in
|
✓ Check in 1
|
||||||
|
</button>
|
||||||
|
{#if rem > 1}
|
||||||
|
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
|
||||||
|
Check in all {rem}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
|
||||||
<span class="gate-done">✓ Checked in {fmt(matchedTicket.checked_in_at)}</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{:else}
|
||||||
</div>
|
<span class="gate-done">All checked in</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Single participant match — show their tickets -->
|
{#if selected.volunteer_token && !selected.checked_in}
|
||||||
{:else if selectedParticipant}
|
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
|
||||||
{@const pts = ticketsFor(selectedParticipant.id)}
|
+ Volunteer
|
||||||
<div class="gate-match">
|
</button>
|
||||||
<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}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{:else if search.trim().length >= 2 && filtered.length > 1}
|
||||||
</div>
|
<!-- Multiple results list -->
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Multiple participant matches -->
|
|
||||||
{:else if search.trim().length >= 2 && filteredParticipants.length > 1}
|
|
||||||
<div class="gate-results">
|
<div class="gate-results">
|
||||||
{#each filteredParticipants as p}
|
{#each filtered as a}
|
||||||
{@const pts = ticketsFor(p.id)}
|
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
|
||||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
|
||||||
<button class="gate-result-row" onclick={() => manuallySelectedId = p.id}>
|
|
||||||
<span>
|
<span>
|
||||||
<strong>{p.preferred_name || p.email || '(unknown)'}</strong>
|
<strong>{a.name}</strong>
|
||||||
{#if p.email && p.preferred_name}
|
{#if a.ticket_type} · {a.ticket_type}{/if}
|
||||||
<span class="text-muted" style="font-size:0.8rem"> · {p.email}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="badge {ci === pts.length && pts.length > 0 ? 'badge-checked' : ci > 0 ? 'badge-partial' : 'badge-unchecked'}">
|
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
{pts.length > 0 ? `${ci}/${pts.length}` : 'No ticket'}
|
{a.checked_in ? 'In' : 'Pending'}
|
||||||
</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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if search.trim().length >= 2 && filtered.length === 0}
|
||||||
|
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Recent check-ins -->
|
<!-- Recent check-ins -->
|
||||||
<div class="gate-recent">
|
<div class="gate-recent">
|
||||||
<div class="gate-recent-title">Recent Check-ins</div>
|
<div class="gate-recent-title">Recent Check-ins</div>
|
||||||
{#if ($recentCheckIns ?? []).length === 0}
|
{#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}
|
{:else}
|
||||||
{#each $recentCheckIns ?? [] as tk}
|
{#each $recentCheckIns ?? [] as a}
|
||||||
<div class="gate-recent-row">
|
<div class="gate-recent-row">
|
||||||
<span>{nameFor(tk)}</span>
|
<span>{a.name}</span>
|
||||||
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
|
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -439,8 +354,7 @@
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
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; margin-bottom: 0.2rem; }
|
||||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; }
|
|
||||||
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
||||||
.gate-party {
|
.gate-party {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
|
|
@ -470,16 +384,6 @@
|
||||||
align-items: center;
|
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 {
|
.gate-results {
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
border: 1px solid var(--c-border);
|
border: 1px solid var(--c-border);
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
<strong style="color:var(--c-text)">Supported formats:</strong><br>
|
<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>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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../api.js'
|
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 state = $state(null) // { volunteer, shifts, available }
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
@ -149,7 +150,7 @@
|
||||||
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
||||||
<div class="kiosk-vol-meta">
|
<div class="kiosk-vol-meta">
|
||||||
{state.volunteer.email || ''}
|
{state.volunteer.email || ''}
|
||||||
{state.volunteer.is_lead ? ' · Co-Lead' : ''}
|
{state.volunteer.is_lead ? ' · Department Lead' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="kiosk-token">Token: <code>{token}</code></div>
|
<div class="kiosk-token">Token: <code>{token}</code></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,32 +1,20 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { saveSession } from '../db.js'
|
import { saveSession } from '../db.js'
|
||||||
|
|
||||||
let { onlogin, error: externalError = '' } = $props()
|
let { onlogin } = $props()
|
||||||
|
|
||||||
let email = $state('')
|
let username = $state('')
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
|
||||||
$effect(() => { if (externalError) error = externalError })
|
|
||||||
let loading = $state(false)
|
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) {
|
async function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
error = ''
|
error = ''
|
||||||
loading = true
|
loading = true
|
||||||
try {
|
try {
|
||||||
const { token, user } = await api.login(email, password)
|
const { token, user } = await api.login(username, password)
|
||||||
await saveSession(token, user)
|
await saveSession(token, user)
|
||||||
onlogin({ token, user })
|
onlogin({ token, user })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -35,18 +23,6 @@
|
||||||
loading = false
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="login-wrap">
|
<div class="login-wrap">
|
||||||
|
|
@ -58,8 +34,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="username">Username</label>
|
||||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
<input id="username" bind:value={username} autocomplete="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
|
|
@ -69,28 +45,5 @@
|
||||||
{loading ? 'Signing in…' : 'Sign in'}
|
{loading ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</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 editShift = $state({})
|
||||||
let saving = $state(false)
|
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
|
// For volunteer assignment dropdown
|
||||||
let assigningShiftID = $state(null)
|
let assigningShiftID = $state(null)
|
||||||
let assignVolID = $state(0)
|
let assignVolID = $state(0)
|
||||||
let assigning = $state(false)
|
let assigning = $state(false)
|
||||||
|
|
||||||
const roles = $derived(session?.user?.roles ?? [])
|
const role = $derived(session?.user?.role ?? '')
|
||||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
|
||||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
|
||||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||||
|
|
||||||
const allDepts = liveQuery(() =>
|
const allDepts = liveQuery(() =>
|
||||||
|
|
@ -55,7 +43,7 @@
|
||||||
// Departments visible to this user
|
// Departments visible to this user
|
||||||
const visibleDepts = $derived.by(() => {
|
const visibleDepts = $derived.by(() => {
|
||||||
const depts = $allDepts ?? []
|
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
|
return depts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -135,13 +123,11 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.shifts.reorder(positions)
|
const res = await api.shifts.reorder(positions)
|
||||||
if (res && !res.ok) throw new Error('Reorder failed')
|
if (res && !res.ok) throw new Error()
|
||||||
await db.transaction('rw', db.shifts, async () => {
|
|
||||||
for (const p of positions) {
|
for (const p of positions) {
|
||||||
const s = await db.shifts.get(p.id)
|
const s = await db.shifts.get(p.id)
|
||||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||||
}
|
}
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
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) {
|
function fmt(t) {
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
const [h, m] = t.split(':').map(Number)
|
const [h, m] = t.split(':').map(Number)
|
||||||
|
|
@ -260,66 +204,17 @@
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Schedule</h1>
|
<h1 class="page-title">Schedule Board</h1>
|
||||||
{#if canManage}
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert alert-error">{error}</div>
|
<div class="alert alert-error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showAdd && canManage}
|
{#if ($allShifts ?? []).length === 0}
|
||||||
<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}
|
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No shifts scheduled yet</strong>
|
<strong>No shifts yet</strong>
|
||||||
<p>Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.</p>
|
<p>Create shifts in the Shifts page first.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each board as { dept, days }}
|
{#each board as { dept, days }}
|
||||||
|
|
@ -331,7 +226,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each days as [day, rows]}
|
{#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}
|
{#each rows as { shift, assigned, hasConflict }, i}
|
||||||
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
||||||
|
|
@ -380,20 +275,17 @@
|
||||||
<span class="board-cap">{assigned.length}</span>
|
<span class="board-cap">{assigned.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasConflict}
|
{#if hasConflict}
|
||||||
<span class="badge badge-lead">⚠ conflict</span>
|
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if canManage}
|
|
||||||
<div class="board-shift-actions">
|
<div class="board-shift-actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
||||||
<button class="btn btn-ghost btn-sm" title="Move up"
|
<button class="btn btn-ghost btn-sm" title="Move up"
|
||||||
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||||
<button class="btn btn-ghost btn-sm" title="Move down"
|
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||||
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assigned volunteers -->
|
<!-- Assigned volunteers -->
|
||||||
|
|
@ -402,28 +294,21 @@
|
||||||
{#each assigned as { vs, volunteer }}
|
{#each assigned as { vs, volunteer }}
|
||||||
<div class="board-vol-chip">
|
<div class="board-vol-chip">
|
||||||
{volunteer.name}
|
{volunteer.name}
|
||||||
{#if volunteer.is_lead}
|
|
||||||
<span class="chip-lead">Co-Lead</span>
|
|
||||||
{/if}
|
|
||||||
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||||
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Assign volunteer -->
|
<!-- Assign volunteer -->
|
||||||
{#if canManage}
|
|
||||||
{#if assigningShiftID === shift.id}
|
{#if assigningShiftID === shift.id}
|
||||||
<div class="board-assign-row">
|
<div class="board-assign-row">
|
||||||
<select bind:value={assignVolID} style="width:auto">
|
<select bind:value={assignVolID} style="width:auto">
|
||||||
<option value={0}>— Select volunteer —</option>
|
<option value={0}>— Select volunteer —</option>
|
||||||
{#each ($allVolunteers ?? [])
|
{#each $allVolunteers ?? [] as v}
|
||||||
.filter(v => v.department_id === shift.department_id)
|
|
||||||
.filter(v => !assigned.some(a => a.volunteer.id === v.id))
|
|
||||||
as v}
|
|
||||||
<option value={v.id}>{v.name}</option>
|
<option value={v.id}>{v.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -439,7 +324,6 @@
|
||||||
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -527,14 +411,6 @@
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
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 {
|
.board-vol-remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { db } from '../db.js'
|
|
||||||
|
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
let savingEvent = $state(false)
|
|
||||||
let testing = $state(false)
|
let testing = $state(false)
|
||||||
let resetting = $state(false)
|
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let success = $state('')
|
let success = $state('')
|
||||||
|
|
||||||
|
|
@ -19,28 +16,8 @@
|
||||||
let smtpFromName = $state('')
|
let smtpFromName = $state('')
|
||||||
let baseURL = $state('')
|
let baseURL = $state('')
|
||||||
let testEmail = $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 () => {
|
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 {
|
try {
|
||||||
const s = await api.settings.get()
|
const s = await api.settings.get()
|
||||||
smtpHost = s.smtp_host ?? ''
|
smtpHost = s.smtp_host ?? ''
|
||||||
|
|
@ -50,11 +27,6 @@
|
||||||
smtpFrom = s.smtp_from ?? ''
|
smtpFrom = s.smtp_from ?? ''
|
||||||
smtpFromName = s.smtp_from_name ?? ''
|
smtpFromName = s.smtp_from_name ?? ''
|
||||||
baseURL = s.base_url ?? ''
|
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) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
} finally {
|
} 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) {
|
async function save(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saving = true
|
saving = true
|
||||||
|
|
@ -94,17 +44,12 @@
|
||||||
smtp_host: smtpHost,
|
smtp_host: smtpHost,
|
||||||
smtp_port: smtpPort,
|
smtp_port: smtpPort,
|
||||||
smtp_user: smtpUser,
|
smtp_user: smtpUser,
|
||||||
smtp_password: smtpPassword,
|
smtp_password: smtpPassword, // empty = keep existing
|
||||||
smtp_from: smtpFrom,
|
smtp_from: smtpFrom,
|
||||||
smtp_from_name: smtpFromName,
|
smtp_from_name: smtpFromName,
|
||||||
base_url: baseURL,
|
base_url: baseURL,
|
||||||
volunteer_note_label: noteLabel,
|
|
||||||
volunteer_note_required: noteRequired,
|
|
||||||
discourse_sso_url: discourseSSOUrl,
|
|
||||||
discourse_sso_secret: discourseSSOSecret,
|
|
||||||
})
|
})
|
||||||
smtpPassword = ''
|
smtpPassword = ''
|
||||||
discourseSSOSecret = ''
|
|
||||||
success = 'Settings saved.'
|
success = 'Settings saved.'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
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() {
|
async function sendTest() {
|
||||||
if (!testEmail) return
|
if (!testEmail) return
|
||||||
testing = true
|
testing = true
|
||||||
|
|
@ -177,50 +89,12 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="text-muted">Loading…</div>
|
<div class="text-muted">Loading…</div>
|
||||||
{:else}
|
{: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}>
|
<form onsubmit={save}>
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">SMTP Email</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group" style="grid-column:1">
|
||||||
<label for="s-host">SMTP Host</label>
|
<label for="s-host">SMTP Host</label>
|
||||||
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -248,27 +122,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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" />
|
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||||
</div>
|
</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">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
|
|
@ -279,7 +136,7 @@
|
||||||
|
|
||||||
<!-- Test email -->
|
<!-- Test email -->
|
||||||
<div class="card">
|
<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 style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||||
<label for="s-test">Send to</label>
|
<label for="s-test">Send to</label>
|
||||||
|
|
@ -290,69 +147,5 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
</div>
|
</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 showAdd = $state(false)
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
let newEmail = $state('')
|
let newUsername = $state('')
|
||||||
let newName = $state('')
|
|
||||||
let newPassword = $state('')
|
let newPassword = $state('')
|
||||||
let newRoles = $state([])
|
let newRole = $state('gate')
|
||||||
let newDeptIDs = $state([])
|
let newDeptIDs = $state([])
|
||||||
|
|
||||||
let editID = $state(null)
|
let editID = $state(null)
|
||||||
let editRoles = $state([])
|
let editRole = $state('')
|
||||||
let editDeptIDs = $state([])
|
let editDeptIDs = $state([])
|
||||||
let editPassword = $state('')
|
let editPassword = $state('')
|
||||||
let saving = $state(false)
|
let saving = $state(false)
|
||||||
|
|
@ -29,7 +28,7 @@
|
||||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
.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)
|
const me = $derived(session?.user?.id)
|
||||||
|
|
||||||
|
|
@ -52,16 +51,15 @@
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const u = await api.users.create({
|
const u = await api.users.create({
|
||||||
email: newEmail,
|
username: newUsername,
|
||||||
preferred_name: newName,
|
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
roles: newRoles,
|
role: newRole,
|
||||||
department_ids: newDeptIDs,
|
department_ids: newDeptIDs,
|
||||||
})
|
})
|
||||||
users = [...users, u]
|
users = [...users, u]
|
||||||
showAdd = false
|
showAdd = false
|
||||||
newEmail = newName = newPassword = ''
|
newUsername = newPassword = ''
|
||||||
newRoles = []
|
newRole = 'gate'
|
||||||
newDeptIDs = []
|
newDeptIDs = []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
|
|
@ -72,7 +70,7 @@
|
||||||
|
|
||||||
function startEdit(u) {
|
function startEdit(u) {
|
||||||
editID = u.id
|
editID = u.id
|
||||||
editRoles = [...(u.roles || [])]
|
editRole = u.role
|
||||||
editDeptIDs = [...(u.department_ids || [])]
|
editDeptIDs = [...(u.department_ids || [])]
|
||||||
editPassword = ''
|
editPassword = ''
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +83,7 @@
|
||||||
saving = true
|
saving = true
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
const payload = { roles: editRoles, department_ids: editDeptIDs }
|
const payload = { role: editRole, department_ids: editDeptIDs }
|
||||||
if (editPassword) payload.password = editPassword
|
if (editPassword) payload.password = editPassword
|
||||||
const updated = await api.users.update(u.id, payload)
|
const updated = await api.users.update(u.id, payload)
|
||||||
users = users.map(x => x.id === u.id ? updated : x)
|
users = users.map(x => x.id === u.id ? updated : x)
|
||||||
|
|
@ -98,7 +96,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(u) {
|
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 {
|
try {
|
||||||
await api.users.delete(u.id)
|
await api.users.delete(u.id)
|
||||||
users = users.filter(x => x.id !== 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)
|
const idx = list.indexOf(id)
|
||||||
if (idx === -1) return [...list, id]
|
if (idx === -1) return [...list, id]
|
||||||
return list.filter(x => x !== id)
|
return list.filter(x => x !== id)
|
||||||
|
|
@ -119,7 +117,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleLabel(r) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -131,14 +129,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#if loadError}
|
||||||
<div class="alert alert-error">{loadError}</div>
|
<div class="alert alert-error">{loadError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -149,31 +139,22 @@
|
||||||
{#if showAdd}
|
{#if showAdd}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addUser}>
|
<form onsubmit={addUser}>
|
||||||
<div class="form-grid-3">
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-email">Email *</label>
|
<label for="u-username">Username *</label>
|
||||||
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
|
<input id="u-username" bind:value={newUsername} required placeholder="username" 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" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="u-password">Password *</label>
|
<label for="u-password">Password *</label>
|
||||||
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
|
<label for="u-role">Role *</label>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
<select id="u-role" bind:value={newRole}>
|
||||||
{#each availableRoles as r}
|
{#each roles as r}
|
||||||
<label class="checkbox-label">
|
<option value={r}>{roleLabel(r)}</option>
|
||||||
<input type="checkbox"
|
|
||||||
checked={newRoles.includes(r)}
|
|
||||||
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
|
||||||
{roleLabel(r)}
|
|
||||||
</label>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if ($allDepts ?? []).length > 0}
|
{#if ($allDepts ?? []).length > 0}
|
||||||
|
|
@ -181,10 +162,10 @@
|
||||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
<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">
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||||
{#each $allDepts ?? [] as d}
|
{#each $allDepts ?? [] as d}
|
||||||
<label class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
checked={newDeptIDs.includes(d.id)}
|
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>
|
<span class="dept-dot" style="background:{d.color}"></span>
|
||||||
{d.name}
|
{d.name}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -206,16 +187,15 @@
|
||||||
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
||||||
{:else if users.length === 0}
|
{:else if users.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No additional users</strong>
|
<strong>No users yet</strong>
|
||||||
<p>The admin account was created at setup. Add users above to delegate access.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Preferred Name</th>
|
<th>Username</th>
|
||||||
<th>Roles</th>
|
<th>Role</th>
|
||||||
<th>Departments</th>
|
<th>Departments</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -223,28 +203,23 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each users as u (u.id)}
|
{#each users as u (u.id)}
|
||||||
{#if editID === u.id}
|
{#if editID === u.id}
|
||||||
<tr class="edit-row">
|
<tr>
|
||||||
<td class="td-name"><strong>{u.preferred_name || u.email}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
<select bind:value={editRole} style="width:auto;margin:0">
|
||||||
{#each availableRoles as r}
|
{#each roles as r}
|
||||||
<label class="checkbox-label-sm">
|
<option value={r}>{roleLabel(r)}</option>
|
||||||
<input type="checkbox"
|
|
||||||
checked={editRoles.includes(r)}
|
|
||||||
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
|
||||||
{roleLabel(r)}
|
|
||||||
</label>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if ($allDepts ?? []).length > 0}
|
{#if ($allDepts ?? []).length > 0}
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||||
{#each $allDepts ?? [] as d}
|
{#each $allDepts ?? [] as d}
|
||||||
<label class="checkbox-label-sm">
|
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
checked={editDeptIDs.includes(d.id)}
|
checked={editDeptIDs.includes(d.id)}
|
||||||
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
||||||
{d.name}
|
{d.name}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -254,7 +229,7 @@
|
||||||
placeholder="New password (leave blank to keep)"
|
placeholder="New password (leave blank to keep)"
|
||||||
style="margin-top:0.5rem" autocomplete="new-password" />
|
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||||
</td>
|
</td>
|
||||||
<td class="td-actions">
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||||
{saving ? '…' : 'Save'}
|
{saving ? '…' : 'Save'}
|
||||||
|
|
@ -265,20 +240,19 @@
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="td-name">
|
<td>
|
||||||
<strong>{u.preferred_name || u.email}</strong>
|
<strong>{u.username}</strong>
|
||||||
{#if u.id === me}
|
{#if u.id === me}
|
||||||
<span class="badge badge-role">you</span>
|
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||||
{/if}
|
{/if}
|
||||||
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
|
|
||||||
</td>
|
</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="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||||
<td class="td-actions">
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||||
{#if u.id !== me}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -290,11 +264,3 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 search = $state('')
|
||||||
let filterDept = $state('')
|
let filterDept = $state('')
|
||||||
let filterStatus = $state('')
|
let filterChecked = $state('')
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let showAdd = $state(false)
|
let showAdd = $state(false)
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
let newName = $state('')
|
let newName = $state('')
|
||||||
let newTicketName = $state('')
|
|
||||||
let newEmail = $state('')
|
let newEmail = $state('')
|
||||||
|
let newPhone = $state('')
|
||||||
let newDeptID = $state('')
|
let newDeptID = $state('')
|
||||||
let newIsLead = $state(false)
|
let newIsLead = $state(false)
|
||||||
let newNote = $state('')
|
let newNote = $state('')
|
||||||
|
|
||||||
let editID = $state(null)
|
const role = $derived(session?.user?.role ?? '')
|
||||||
let editDeptID = $state('')
|
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||||
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 allVolunteers = liveQuery(() =>
|
const allVolunteers = liveQuery(() =>
|
||||||
db.volunteers.filter(v => !v.deleted_at).toArray()
|
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(() =>
|
const allDepts = liveQuery(() =>
|
||||||
db.departments.filter(d => !d.deleted_at).toArray()
|
db.departments.filter(d => !d.deleted_at).toArray()
|
||||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
@ -56,10 +36,8 @@
|
||||||
return list
|
return list
|
||||||
.filter(v => {
|
.filter(v => {
|
||||||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||||
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
if (filterChecked === 'true' && !v.checked_in) return false
|
||||||
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
if (filterChecked === 'false' && v.checked_in) return false
|
||||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
|
|
||||||
if (filterStatus === 'ready' && !v.ready) return false
|
|
||||||
if (s && !v.name.toLowerCase().includes(s) &&
|
if (s && !v.name.toLowerCase().includes(s) &&
|
||||||
!(v.email || '').toLowerCase().includes(s)) return false
|
!(v.email || '').toLowerCase().includes(s)) return false
|
||||||
return true
|
return true
|
||||||
|
|
@ -67,28 +45,15 @@
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
async function markReady(v) {
|
async function checkIn(v) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.volunteers.markReady(v.id)
|
const updated = await api.volunteers.checkIn(v.id)
|
||||||
await db.volunteers.put(updated)
|
await db.volunteers.put(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
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) {
|
async function addVolunteer(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
adding = true
|
adding = true
|
||||||
|
|
@ -96,8 +61,8 @@
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
name: newName,
|
name: newName,
|
||||||
ticket_name: newTicketName,
|
|
||||||
email: newEmail,
|
email: newEmail,
|
||||||
|
phone: newPhone,
|
||||||
is_lead: newIsLead,
|
is_lead: newIsLead,
|
||||||
note: newNote,
|
note: newNote,
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +70,7 @@
|
||||||
const v = await api.volunteers.create(data)
|
const v = await api.volunteers.create(data)
|
||||||
await db.volunteers.put(v)
|
await db.volunteers.put(v)
|
||||||
showAdd = false
|
showAdd = false
|
||||||
newName = newEmail = newTicketName = newNote = ''
|
newName = newEmail = newPhone = newNote = ''
|
||||||
newDeptID = ''
|
newDeptID = ''
|
||||||
newIsLead = false
|
newIsLead = false
|
||||||
} catch (err) {
|
} 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) {
|
function deptFor(id) {
|
||||||
return ($allDepts ?? []).find(d => d.id === 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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
|
@ -186,18 +112,18 @@
|
||||||
{#if showAdd && canManage}
|
{#if showAdd && canManage}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<form onsubmit={addVolunteer}>
|
<form onsubmit={addVolunteer}>
|
||||||
<div class="form-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-name">Preferred Name *</label>
|
<label for="v-name">Name *</label>
|
||||||
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
|
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-ticket-name">Name on Ticket</label>
|
<label for="v-email">Email</label>
|
||||||
<input id="v-ticket-name" bind:value={newTicketName} placeholder="Legal/ticketed name" />
|
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-email">Email *</label>
|
<label for="v-phone">Phone</label>
|
||||||
<input id="v-email" type="email" bind:value={newEmail} required placeholder="email@example.com" />
|
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="v-dept">Department</label>
|
<label for="v-dept">Department</label>
|
||||||
|
|
@ -214,8 +140,8 @@
|
||||||
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:1rem">
|
<div style="margin-bottom:1rem">
|
||||||
<label class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||||
<input type="checkbox" bind:checked={newIsLead} />
|
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
||||||
Department lead
|
Department lead
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -239,12 +165,10 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{/if}
|
{/if}
|
||||||
<select bind:value={filterStatus} style="width:auto">
|
<select bind:value={filterChecked} style="width:auto">
|
||||||
<option value="">All statuses</option>
|
<option value="">All</option>
|
||||||
<option value="unconfirmed">Unregistered</option>
|
<option value="false">Not checked in</option>
|
||||||
<option value="registered">Registered</option>
|
<option value="true">Checked in</option>
|
||||||
<option value="confirmed">Confirmed</option>
|
|
||||||
<option value="ready">Ready</option>
|
|
||||||
</select>
|
</select>
|
||||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||||
{filtered.length} shown
|
{filtered.length} shown
|
||||||
|
|
@ -254,14 +178,14 @@
|
||||||
{#if ($allVolunteers ?? []).length === 0}
|
{#if ($allVolunteers ?? []).length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No volunteers yet</strong>
|
<strong>No volunteers yet</strong>
|
||||||
<p>Add volunteers manually above, or enable public signup in Settings.</p>
|
<p>Add volunteers manually.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Preferred Name</th>
|
<th>Name</th>
|
||||||
<th>Department</th>
|
<th>Department</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
|
@ -271,112 +195,47 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each filtered as v (v.id)}
|
{#each filtered as v (v.id)}
|
||||||
{@const dept = deptFor(v.department_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>
|
<tr>
|
||||||
<td class="td-name">
|
<td>
|
||||||
<strong>{v.name}</strong>
|
<strong>{v.name}</strong>
|
||||||
{#if v.is_lead}
|
{#if v.is_lead}
|
||||||
<span class="badge badge-lead">Co-Lead</span>
|
<span class="badge badge-lead" style="margin-left:0.4rem">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>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if v.note}
|
{#if v.note}
|
||||||
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-dept text-muted">
|
<td class="text-muted">
|
||||||
{#if dept}
|
{#if dept}
|
||||||
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
||||||
{:else}
|
{:else}
|
||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-status">
|
<td>
|
||||||
{#if v.ready}
|
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||||
<span class="badge badge-checked">Ready</span>
|
{v.checked_in ? 'Checked in' : 'Pending'}
|
||||||
{:else if v.confirmed}
|
</span>
|
||||||
<span class="badge badge-confirmed">Confirmed</span>
|
{#if v.checked_in_at}
|
||||||
{: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}
|
|
||||||
<div class="text-muted" style="font-size:0.75rem">
|
<div class="text-muted" style="font-size:0.75rem">
|
||||||
{new Date(v.ready_at).toLocaleTimeString()}
|
{new Date(v.checked_in_at).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="td-ready">
|
<td>
|
||||||
{#if v.confirmed && !v.ready}
|
{#if !v.checked_in}
|
||||||
<CheckInButton onclick={() => markReady(v)} />
|
<CheckInButton onclick={() => checkIn(v)} />
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if canManage}
|
{#if canManage}
|
||||||
<td class="td-actions">
|
<td>
|
||||||
{#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>
|
|
||||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 syncing = false
|
||||||
let sseSource = null
|
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() {
|
export async function syncPull() {
|
||||||
if (syncing) return
|
if (syncing) return
|
||||||
syncing = true
|
syncing = true
|
||||||
try {
|
try {
|
||||||
await checkBuildChanged()
|
|
||||||
const since = await getLastSync()
|
const since = await getLastSync()
|
||||||
const data = await api.sync.pull(since)
|
const data = await api.sync.pull(since)
|
||||||
|
|
||||||
await db.transaction('rw',
|
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 () => {
|
async () => {
|
||||||
if (data.event) {
|
if (data.event) {
|
||||||
await db.event.put(data.event)
|
await db.event.put(data.event)
|
||||||
}
|
}
|
||||||
if (data.participants?.length) {
|
if (data.attendees?.length) {
|
||||||
await db.participants.bulkPut(data.participants)
|
await db.attendees.bulkPut(data.attendees)
|
||||||
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
|
// Purge hard-deleted records from Dexie
|
||||||
if (deleted.length) await db.participants.bulkDelete(deleted)
|
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||||
}
|
if (deleted.length) await db.attendees.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.departments?.length) {
|
if (data.departments?.length) {
|
||||||
await db.departments.bulkPut(data.departments)
|
await db.departments.bulkPut(data.departments)
|
||||||
|
|
@ -70,14 +40,11 @@ export async function syncPull() {
|
||||||
}
|
}
|
||||||
if (data.volunteer_shifts?.length) {
|
if (data.volunteer_shifts?.length) {
|
||||||
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
|
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
|
||||||
const deleted = data.volunteer_shifts.filter(vs => vs.deleted_at)
|
|
||||||
.map(vs => [vs.volunteer_id, vs.shift_id])
|
|
||||||
if (deleted.length) await db.volunteer_shifts.bulkDelete(deleted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (data.server_time) await setLastSync(data.server_time)
|
await setLastSync(data.server_time)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Sync pull failed:', err.message)
|
console.warn('Sync pull failed:', err.message)
|
||||||
|
|
@ -98,32 +65,29 @@ export function startSSE(onEvent) {
|
||||||
|
|
||||||
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
|
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
|
||||||
|
|
||||||
sseSource.onmessage = async (e) => {
|
sseSource.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(e.data)
|
const payload = JSON.parse(e.data)
|
||||||
if (payload.event === 'checkin') {
|
if (payload.event === 'checkin') {
|
||||||
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
|
// Apply check-in to local Dexie immediately
|
||||||
await db.tickets.put(payload.data.ticket)
|
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||||
|
db.attendees.put(payload.data.attendee)
|
||||||
}
|
}
|
||||||
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
||||||
await db.volunteers.put(payload.data.volunteer)
|
db.volunteers.put(payload.data.volunteer)
|
||||||
}
|
}
|
||||||
onEvent?.(payload)
|
onEvent?.(payload)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {}
|
||||||
console.warn('SSE message error:', err.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sseSource.onerror = () => {
|
sseSource.onerror = () => {
|
||||||
sseSource?.close()
|
sseSource?.close()
|
||||||
sseSource = null
|
sseSource = null
|
||||||
setTimeout(() => {
|
// Reconnect after 5s
|
||||||
connect()
|
setTimeout(connect, 5000)
|
||||||
syncPull()
|
|
||||||
}, 5000)
|
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
connect()
|
connect()
|
||||||
|
|
@ -134,23 +98,18 @@ export function stopSSE() {
|
||||||
sseSource = null
|
sseSource = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll for sync when online, with exponential backoff on failure
|
||||||
let syncInterval = null
|
let syncInterval = null
|
||||||
let onlineHandler = null
|
|
||||||
|
|
||||||
export function startSyncLoop(intervalMs = 30000) {
|
export function startSyncLoop(intervalMs = 30000) {
|
||||||
if (syncInterval) return
|
if (syncInterval) return
|
||||||
syncInterval = setInterval(() => {
|
syncInterval = setInterval(() => {
|
||||||
if (navigator.onLine) syncPull()
|
if (navigator.onLine) syncPull()
|
||||||
}, intervalMs)
|
}, intervalMs)
|
||||||
onlineHandler = () => syncPull()
|
window.addEventListener('online', () => syncPull())
|
||||||
window.addEventListener('online', onlineHandler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSyncLoop() {
|
export function stopSyncLoop() {
|
||||||
clearInterval(syncInterval)
|
clearInterval(syncInterval)
|
||||||
syncInterval = null
|
syncInterval = null
|
||||||
if (onlineHandler) {
|
|
||||||
window.removeEventListener('online', onlineHandler)
|
|
||||||
onlineHandler = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { db, getLastSync, setLastSync } from './db.js'
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await Promise.all(db.tables.map(t => t.clear()))
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
function mockFetch(body = {}, status = 200) {
|
|
||||||
globalThis.fetch = vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
statusText: 'OK',
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('syncPull', () => {
|
|
||||||
it('writes participants to Dexie', async () => {
|
|
||||||
mockFetch({
|
|
||||||
server_time: '2026-03-01T12:00:00Z',
|
|
||||||
participants: [{ id: 1, preferred_name: 'Titania', email: 'titania@example.com' }],
|
|
||||||
tickets: [],
|
|
||||||
departments: [],
|
|
||||||
volunteers: [],
|
|
||||||
shifts: [],
|
|
||||||
volunteer_shifts: [],
|
|
||||||
})
|
|
||||||
const { syncPull } = await import('./sync.js')
|
|
||||||
await syncPull()
|
|
||||||
|
|
||||||
const p = await db.participants.get(1)
|
|
||||||
expect(p.preferred_name).toBe('Titania')
|
|
||||||
expect(await getLastSync()).toBe('2026-03-01T12:00:00Z')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes soft-deleted participants from Dexie', async () => {
|
|
||||||
await db.participants.put({ id: 1, preferred_name: 'Titania', email: 'titania@example.com' })
|
|
||||||
|
|
||||||
mockFetch({
|
|
||||||
server_time: '2026-03-01T13:00:00Z',
|
|
||||||
participants: [{ id: 1, preferred_name: 'Titania', deleted_at: '2026-03-01T12:30:00Z' }],
|
|
||||||
tickets: [],
|
|
||||||
departments: [],
|
|
||||||
volunteers: [],
|
|
||||||
shifts: [],
|
|
||||||
volunteer_shifts: [],
|
|
||||||
})
|
|
||||||
const { syncPull } = await import('./sync.js')
|
|
||||||
await syncPull()
|
|
||||||
|
|
||||||
const p = await db.participants.get(1)
|
|
||||||
expect(p).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes soft-deleted volunteer_shifts from Dexie', async () => {
|
|
||||||
await db.volunteer_shifts.put({ volunteer_id: 1, shift_id: 2 })
|
|
||||||
|
|
||||||
mockFetch({
|
|
||||||
server_time: '2026-03-01T13:00:00Z',
|
|
||||||
participants: [],
|
|
||||||
tickets: [],
|
|
||||||
departments: [],
|
|
||||||
volunteers: [],
|
|
||||||
shifts: [],
|
|
||||||
volunteer_shifts: [{ volunteer_id: 1, shift_id: 2, deleted_at: '2026-03-01T12:30:00Z' }],
|
|
||||||
})
|
|
||||||
const { syncPull } = await import('./sync.js')
|
|
||||||
await syncPull()
|
|
||||||
|
|
||||||
const vs = await db.volunteer_shifts.get([1, 2])
|
|
||||||
expect(vs).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets lastSync timestamp', async () => {
|
|
||||||
mockFetch({
|
|
||||||
server_time: '2026-03-02T00:00:00Z',
|
|
||||||
participants: [],
|
|
||||||
tickets: [],
|
|
||||||
departments: [],
|
|
||||||
volunteers: [],
|
|
||||||
shifts: [],
|
|
||||||
volunteer_shifts: [],
|
|
||||||
})
|
|
||||||
const { syncPull } = await import('./sync.js')
|
|
||||||
await syncPull()
|
|
||||||
expect(await getLastSync()).toBe('2026-03-02T00:00:00Z')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
import 'fake-indexeddb/auto'
|
|
||||||
|
|
@ -3,9 +3,6 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
define: {
|
|
||||||
__BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'),
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8180',
|
'/api': 'http://localhost:8180',
|
||||||
|
|
@ -15,8 +12,4 @@ export default defineConfig({
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: ['./src/test-setup.js'],
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module turnpike
|
module turnpike
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
|
|
||||||
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()
|
||||||
|
}
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParticipantsListCreateDelete(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
// Create
|
|
||||||
req := testAuthRequest("POST", "/api/participants", map[string]string{"preferred_name": "Titania", "email": "titania@example.com"}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
created := parseJSON(t, w)
|
|
||||||
id := created["id"].(float64)
|
|
||||||
|
|
||||||
// List
|
|
||||||
req = testAuthRequest("GET", "/api/participants", nil, token)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("list: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
list := parseJSON(t, w)
|
|
||||||
participants := list["participants"].([]any)
|
|
||||||
if len(participants) != 2 { // admin + Titania
|
|
||||||
t.Errorf("list: got %d, want 2", len(participants))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
req = testAuthRequest("DELETE", "/api/participants/"+itoa(int(id)), nil, token)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Errorf("delete: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List again — should be empty
|
|
||||||
req = testAuthRequest("GET", "/api/participants", 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckInTicketHandler(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"})
|
|
||||||
|
|
||||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("checkin: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
ticket := result["ticket"].(map[string]any)
|
|
||||||
if ticket["checked_in_at"] == nil {
|
|
||||||
t.Error("checked_in_at should be set after check-in")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatekeeperRoleCanCheckIn(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []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"})
|
|
||||||
|
|
||||||
req := testAuthRequest("POST", "/api/tickets/"+itoa(tk.ID)+"/checkin", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("gatekeeper checkin: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatekeeperRoleCannotDelete(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
gate := testUserWithRoles(t, app, "Philostrate", []string{"gatekeeper"}, []int{})
|
|
||||||
token := testToken(t, app, gate)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@example.com"})
|
|
||||||
|
|
||||||
req := testAuthRequest("DELETE", "/api/participants/"+itoa(p.ID), nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("gatekeeper delete: status = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
|
|
||||||
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, hash, err := app.getLoginParticipant(body.Email)
|
user, hash, err := app.getUserByUsername(body.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "internal error", http.StatusInternalServerError)
|
writeError(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
user, err := app.getUser(claims.ParticipantID)
|
user, err := app.getUserByID(claims.UserID)
|
||||||
if err != nil || user == nil {
|
if err != nil || user == nil {
|
||||||
writeError(w, "unauthorized", http.StatusUnauthorized)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, user)
|
writeJSON(w, user)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
Inserted int `json:"inserted"`
|
Inserted int `json:"inserted"`
|
||||||
|
Grouped int `json:"grouped"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
|
|
@ -56,14 +57,12 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int
|
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int
|
||||||
hasEmail, hasTicketID, hasTicketType bool
|
hasEmail, hasTicketID, hasTicketType, hasNote bool
|
||||||
isCrowdWork bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if idx, ok := colIndex["patron name"]; ok {
|
if idx, ok := colIndex["patron name"]; ok {
|
||||||
// CrowdWork / ticketing platform format
|
// CrowdWork / ticketing platform format
|
||||||
isCrowdWork = true
|
|
||||||
nameIdx = idx
|
nameIdx = idx
|
||||||
if idx, ok := colIndex["patron email"]; ok {
|
if idx, ok := colIndex["patron email"]; ok {
|
||||||
emailIdx, hasEmail = idx, true
|
emailIdx, hasEmail = idx, true
|
||||||
|
|
@ -86,6 +85,9 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
||||||
if idx, ok := colIndex["ticket_type"]; ok {
|
if idx, ok := colIndex["ticket_type"]; ok {
|
||||||
ticketTypeIdx, hasTicketType = idx, true
|
ticketTypeIdx, hasTicketType = idx, true
|
||||||
}
|
}
|
||||||
|
if idx, ok := colIndex["note"]; ok {
|
||||||
|
noteIdx, hasNote = idx, true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column")
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
email := ""
|
a := Attendee{Name: name}
|
||||||
if hasEmail {
|
if hasEmail {
|
||||||
email = strings.TrimSpace(csvGet(record, emailIdx))
|
a.Email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||||
}
|
}
|
||||||
externalID := ""
|
|
||||||
if hasTicketID {
|
if hasTicketID {
|
||||||
externalID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||||
}
|
}
|
||||||
ticketType := ""
|
|
||||||
if hasTicketType {
|
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"
|
_, err = app.createAttendee(a)
|
||||||
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,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
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++
|
result.Skipped++
|
||||||
} else {
|
} else {
|
||||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func postCSV(t *testing.T, mux *http.ServeMux, token, csv string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
writer := multipart.NewWriter(&buf)
|
|
||||||
part, _ := writer.CreateFormFile("csv", "attendees.csv")
|
|
||||||
io.WriteString(part, csv)
|
|
||||||
writer.Close()
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/import", &buf)
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportCrowdWorkFormat(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nOberon,oberon@test.com,ORD-2,VIP\n"
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["inserted"] != float64(2) {
|
|
||||||
t.Errorf("inserted = %v, want 2", result["inserted"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportGenericFormat(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
csv := "name,email,ticket_id,ticket_type,note\nTitania,titania@test.com,T1,GA,VIP guest\n"
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["inserted"] != float64(1) {
|
|
||||||
t.Errorf("inserted = %v", result["inserted"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportDedup(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)
|
|
||||||
csv := "Patron Name,Patron Email,Order Number,Tier Name\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\nTitania,titania@test.com,ORD-1,GA\n"
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["inserted"] != float64(1) {
|
|
||||||
t.Errorf("inserted = %v, want 1", result["inserted"])
|
|
||||||
}
|
|
||||||
if result["skipped"] != float64(2) {
|
|
||||||
t.Errorf("skipped = %v, want 2", result["skipped"])
|
|
||||||
}
|
|
||||||
|
|
||||||
tickets, _ := app.listTickets(nil, "")
|
|
||||||
if len(tickets) != 1 {
|
|
||||||
t.Fatalf("ticket count = %d, want 1", len(tickets))
|
|
||||||
}
|
|
||||||
if tickets[0].Source != "crowdwork" {
|
|
||||||
t.Errorf("source = %q, want crowdwork", tickets[0].Source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportReimportSkips(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
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"
|
|
||||||
postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
// Re-import same data
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["inserted"] != float64(0) {
|
|
||||||
t.Errorf("re-import inserted = %v, want 0", result["inserted"])
|
|
||||||
}
|
|
||||||
if result["skipped"] != float64(2) {
|
|
||||||
t.Errorf("skipped = %v, want 2", result["skipped"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportMissingNameColumn(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
csv := "email,phone\ntitania@test.com,555-1234\n"
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportBOM(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
// BOM-encoded CSV
|
|
||||||
csv := "\xef\xbb\xbfname,email\nTitania,titania@test.com\n"
|
|
||||||
w := postCSV(t, mux, token, csv)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["inserted"] != float64(1) {
|
|
||||||
t.Errorf("inserted = %v, want 1", result["inserted"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
|
|
||||||
return app.getVolunteerByKioskCode(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
// 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.
|
// no JWT required.
|
||||||
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.PathValue("token")
|
token := r.PathValue("token")
|
||||||
v, err := app.volunteerFromKioskToken(token)
|
a, err := app.getAttendeeByToken(token)
|
||||||
if err != nil || v == nil {
|
if err != nil || a == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
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)
|
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
||||||
if assigned == nil {
|
if assigned == nil {
|
||||||
assigned = []Shift{}
|
assigned = []Shift{}
|
||||||
|
|
@ -51,11 +52,16 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := app.volunteerFromKioskToken(token)
|
a, err := app.getAttendeeByToken(token)
|
||||||
if err != nil || v == nil {
|
if err != nil || a == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
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"
|
force := r.URL.Query().Get("force") == "true"
|
||||||
|
|
||||||
|
|
@ -81,12 +87,15 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "shift not found", http.StatusNotFound)
|
writeError(w, "shift not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if shift.Capacity > 0 {
|
||||||
if err := app.assignShiftWithCapacity(v.ID, shiftID, shift.Capacity); err != nil {
|
count, _ := app.shiftAssignedCount(shiftID)
|
||||||
if errors.Is(err, errShiftFull) {
|
if count >= shift.Capacity {
|
||||||
writeError(w, "shift is full", http.StatusConflict)
|
writeError(w, "shift is full", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.assignShift(v.ID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -103,11 +112,16 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := app.volunteerFromKioskToken(token)
|
a, err := app.getAttendeeByToken(token)
|
||||||
if err != nil || v == nil {
|
if err != nil || a == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
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 {
|
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupKiosk(t *testing.T) (*App, *http.ServeMux, string) {
|
|
||||||
t.Helper()
|
|
||||||
app := testApp(t)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
|
|
||||||
// Create 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 shifts
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Afternoon", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00", Capacity: 1})
|
|
||||||
|
|
||||||
return app, mux, token
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskGetValid(t *testing.T) {
|
|
||||||
_, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v/"+token, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["volunteer"] == nil {
|
|
||||||
t.Error("missing volunteer")
|
|
||||||
}
|
|
||||||
available := result["available"].([]any)
|
|
||||||
if len(available) != 2 {
|
|
||||||
t.Errorf("available = %d, want 2", len(available))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskGetInvalidToken(t *testing.T) {
|
|
||||||
_, mux, _ := setupKiosk(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v/BADTOKEN", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("status = %d, want 404", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskClaimShift(t *testing.T) {
|
|
||||||
_, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("claim: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
shifts := result["shifts"].([]any)
|
|
||||||
if len(shifts) != 1 {
|
|
||||||
t.Errorf("assigned shifts = %d, want 1", len(shifts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskClaimConflict(t *testing.T) {
|
|
||||||
app, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
|
||||||
deptID := dept.ID
|
|
||||||
// Create overlapping shift
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
|
||||||
|
|
||||||
// Claim morning (08:00-12:00)
|
|
||||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("first claim: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claim overlapping shift (10:00-14:00) — should get 409
|
|
||||||
req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("conflict: status = %d, want 409\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["conflict"] != true {
|
|
||||||
t.Error("missing conflict flag")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskClaimForce(t *testing.T) {
|
|
||||||
app, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
|
||||||
deptID := dept.ID
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
|
||||||
|
|
||||||
// Claim morning
|
|
||||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Force-claim overlapping shift
|
|
||||||
req = httptest.NewRequest("POST", "/api/v/"+token+"/shifts/3?force=true", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("force: status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskClaimFull(t *testing.T) {
|
|
||||||
app, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
// Shift 2 has capacity 1. Fill it with another volunteer.
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Build"})
|
|
||||||
deptID := dept.ID
|
|
||||||
otherP, _ := app.createParticipant(Participant{PreferredName: "Other", Email: "other@test.com"})
|
|
||||||
other, _ := app.createVolunteer(Volunteer{ParticipantID: otherP.ID, DepartmentID: &deptID})
|
|
||||||
app.assignShift(other.ID, 2) // fills the capacity-1 shift
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/2", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusConflict {
|
|
||||||
t.Errorf("full: status = %d, want 409\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKioskUnclaim(t *testing.T) {
|
|
||||||
_, mux, token := setupKiosk(t)
|
|
||||||
|
|
||||||
// Claim then unclaim
|
|
||||||
req := httptest.NewRequest("POST", "/api/v/"+token+"/shifts/1", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
req = httptest.NewRequest("DELETE", "/api/v/"+token+"/shifts/1", nil)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("unclaim: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
shifts := result["shifts"].([]any)
|
|
||||||
if len(shifts) != 0 {
|
|
||||||
t.Errorf("after unclaim: shifts = %d, want 0", len(shifts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 = "***"
|
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{
|
writeJSON(w, map[string]any{
|
||||||
"smtp_host": cfg.Host,
|
"smtp_host": cfg.Host,
|
||||||
"smtp_port": cfg.Port,
|
"smtp_port": cfg.Port,
|
||||||
|
|
@ -43,11 +27,6 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
"smtp_from": cfg.From,
|
"smtp_from": cfg.From,
|
||||||
"smtp_from_name": cfg.FromName,
|
"smtp_from_name": cfg.FromName,
|
||||||
"base_url": baseURL,
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
|
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"}
|
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
v, ok := body[k]
|
v, ok := body[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -68,18 +46,12 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
var val string
|
var val string
|
||||||
switch vv := v.(type) {
|
switch vv := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
|
if k == "smtp_password" && vv == "" {
|
||||||
continue
|
continue // don't erase the stored password with an empty value
|
||||||
}
|
}
|
||||||
val = vv
|
val = vv
|
||||||
case float64:
|
case float64:
|
||||||
val = strconv.Itoa(int(vv))
|
val = strconv.Itoa(int(vv))
|
||||||
case bool:
|
|
||||||
if vv {
|
|
||||||
val = "true"
|
|
||||||
} else {
|
|
||||||
val = "false"
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -89,66 +61,6 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
app.handleGetSettings(w, r)
|
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) {
|
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetSettings(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/settings", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["smtp_host"] == nil {
|
|
||||||
t.Error("missing smtp_host key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateSettings(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testAuthRequest("PUT", "/api/settings", map[string]any{
|
|
||||||
"smtp_host": "smtp.example.com",
|
|
||||||
"smtp_port": 587,
|
|
||||||
"smtp_password": "secret",
|
|
||||||
"base_url": "https://turnpike.example.com",
|
|
||||||
}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
if result["smtp_host"] != "smtp.example.com" {
|
|
||||||
t.Errorf("smtp_host = %v", result["smtp_host"])
|
|
||||||
}
|
|
||||||
if result["smtp_password"] != "***" {
|
|
||||||
t.Errorf("smtp_password = %v, want '***'", result["smtp_password"])
|
|
||||||
}
|
|
||||||
if result["base_url"] != "https://turnpike.example.com" {
|
|
||||||
t.Errorf("base_url = %v", result["base_url"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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{})
|
|
||||||
token := testToken(t, app, gate)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/settings", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("status = %d, want 403", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,19 +8,20 @@ import (
|
||||||
|
|
||||||
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
var deptIDs []int
|
var deptID *int
|
||||||
if d := q.Get("dept"); d != "" {
|
if d := q.Get("dept"); d != "" {
|
||||||
if id, err := strconv.Atoi(d); err == nil {
|
id, err := strconv.Atoi(d)
|
||||||
deptIDs = []int{id}
|
if err == nil {
|
||||||
|
deptID = &id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||||
deptIDs = claims.DeptIDs
|
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 {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -39,7 +40,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
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)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +65,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) {
|
if claims.Role == "volunteer_lead" {
|
||||||
existing, _ := app.getShift(id)
|
existing, _ := app.getShift(id)
|
||||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
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)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err := app.deleteShift(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -118,14 +111,6 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
|
||||||
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if !body.Force {
|
||||||
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
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)
|
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -190,16 +167,6 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||||
return
|
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))
|
positions := make([]struct{ ID, Position int }, len(raw))
|
||||||
for i, p := range raw {
|
for i, p := range raw {
|
||||||
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||||
|
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestShiftsCRUDHandler(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
|
|
||||||
// Create
|
|
||||||
req := testAuthRequest("POST", "/api/shifts", map[string]any{
|
|
||||||
"department_id": dept.ID,
|
|
||||||
"name": "Morning",
|
|
||||||
"day": "2026-03-15",
|
|
||||||
"start_time": "08:00",
|
|
||||||
"end_time": "12:00",
|
|
||||||
"capacity": 5,
|
|
||||||
}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// List
|
|
||||||
req = testAuthRequest("GET", "/api/shifts", nil, token)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("list: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
req = testAuthRequest("DELETE", "/api/shifts/1", nil, token)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Errorf("delete: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShiftAssignVolunteer(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
|
||||||
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
// Assign
|
|
||||||
req := testAuthRequest("POST", "/api/shifts/1/volunteers", map[string]any{
|
|
||||||
"volunteer_id": 1,
|
|
||||||
}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("assign: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unassign
|
|
||||||
req = testAuthRequest("DELETE", "/api/shifts/1/volunteers/1", nil, token)
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Errorf("unassign: status = %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShiftAssignConflict(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@test.com"})
|
|
||||||
app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
|
|
||||||
// Assign to first shift
|
|
||||||
app.assignShift(1, 1)
|
|
||||||
|
|
||||||
// Try to assign to overlapping shift — should get 409
|
|
||||||
req := testAuthRequest("POST", "/api/shifts/2/volunteers", map[string]any{
|
|
||||||
"volunteer_id": 1,
|
|
||||||
}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("conflict: status = %d, want 409", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"})
|
|
||||||
|
|
||||||
req := testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{
|
|
||||||
{"id": 1, "position": 2},
|
|
||||||
{"id": 2, "position": 1},
|
|
||||||
}, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNoContent {
|
|
||||||
t.Errorf("reorder: status = %d\nbody: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
s1, _ := app.getShift(1)
|
|
||||||
s2, _ := app.getShift(2)
|
|
||||||
if s1.Position != 2 || s2.Position != 1 {
|
|
||||||
t.Errorf("positions: s1=%d, s2=%d, want 2,1", s1.Position, s2.Position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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")
|
since := r.URL.Query().Get("since")
|
||||||
|
|
||||||
event, _ := app.getEvent()
|
event, _ := app.getEvent()
|
||||||
participants, _ := app.listParticipants("", since)
|
attendees, _ := app.attendeesSince(since)
|
||||||
tickets, _ := app.listTickets(nil, since)
|
|
||||||
departments, _ := app.listDepartments(since)
|
departments, _ := app.listDepartments(since)
|
||||||
volunteers, _ := app.listVolunteers("", nil, since)
|
volunteers, _ := app.listVolunteers("", nil, since)
|
||||||
shifts, _ := app.listShifts(nil, "", since)
|
shifts, _ := app.listShifts(nil, "", since)
|
||||||
volunteerShifts, _ := app.listVolunteerShifts(since)
|
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||||
|
|
||||||
if participants == nil {
|
if attendees == nil {
|
||||||
participants = []Participant{}
|
attendees = []Attendee{}
|
||||||
}
|
|
||||||
if tickets == nil {
|
|
||||||
tickets = []Ticket{}
|
|
||||||
}
|
}
|
||||||
if departments == nil {
|
if departments == nil {
|
||||||
departments = []Department{}
|
departments = []Department{}
|
||||||
|
|
@ -41,8 +37,7 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
"event": event,
|
"event": event,
|
||||||
"participants": participants,
|
"attendees": attendees,
|
||||||
"tickets": tickets,
|
|
||||||
"departments": departments,
|
"departments": departments,
|
||||||
"volunteers": volunteers,
|
"volunteers": volunteers,
|
||||||
"shifts": shifts,
|
"shifts": shifts,
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSyncPullFull(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
p, _ := app.createParticipant(Participant{PreferredName: "Titania", Email: "titania@example.com"})
|
|
||||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
|
||||||
deptID := dept.ID
|
|
||||||
app.createVolunteer(Volunteer{Name: "Titania", ParticipantID: p.ID, DepartmentID: &deptID})
|
|
||||||
app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/sync/pull", nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", w.Code)
|
|
||||||
}
|
|
||||||
result := parseJSON(t, w)
|
|
||||||
|
|
||||||
if result["server_time"] == nil {
|
|
||||||
t.Error("missing server_time")
|
|
||||||
}
|
|
||||||
participants := result["participants"].([]any)
|
|
||||||
if len(participants) != 2 { // admin + Titania
|
|
||||||
t.Errorf("participants = %d, want 2", len(participants))
|
|
||||||
}
|
|
||||||
depts := result["departments"].([]any)
|
|
||||||
if len(depts) != 1 {
|
|
||||||
t.Errorf("departments = %d, want 1", len(depts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSyncPullIncremental(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
token := testToken(t, app, admin)
|
|
||||||
mux := testMux(app)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
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"})
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSyncPullIncludesSoftDeleted(t *testing.T) {
|
|
||||||
app := testApp(t)
|
|
||||||
admin := testAdminUser(t, app)
|
|
||||||
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)
|
|
||||||
|
|
||||||
since := "2026-01-01T12:00:00Z"
|
|
||||||
|
|
||||||
// Delete updates updated_at to now(), which is after our since
|
|
||||||
app.deleteParticipant(p.ID)
|
|
||||||
|
|
||||||
req := testAuthRequest("GET", "/api/sync/pull?since="+since, nil, token)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Participants []struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
DeletedAt *string `json:"deleted_at"`
|
|
||||||
} `json:"participants"`
|
|
||||||
}
|
|
||||||
json.Unmarshal(w.Body.Bytes(), &result)
|
|
||||||
|
|
||||||
if len(result.Participants) != 1 {
|
|
||||||
t.Fatalf("got %d participants, want 1", len(result.Participants))
|
|
||||||
}
|
|
||||||
if result.Participants[0].DeletedAt == nil {
|
|
||||||
t.Error("deleted_at should be set for soft-deleted record")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"strings"
|
"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) {
|
func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
count, err := app.generateCodesForAll()
|
count, err := app.generateTokensForAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
// handleExportTokenLinks streams a CSV download with token signup links,
|
// handleExportTokenLinks streams a CSV download with token signup links,
|
||||||
// compatible with MailChimp / Zeffy bulk-send workflows.
|
// compatible with MailChimp / Zeffy bulk-send workflows.
|
||||||
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
tickets, err := app.listTickets(nil, "")
|
attendees, err := app.listAttendees("", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`)
|
||||||
wr := csv.NewWriter(w)
|
wr := csv.NewWriter(w)
|
||||||
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
||||||
for _, tk := range tickets {
|
for _, a := range attendees {
|
||||||
if tk.Code == nil || tk.ParticipantID == nil {
|
if a.VolunteerToken == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p, _ := app.getParticipant(*tk.ParticipantID)
|
firstName := a.Name
|
||||||
if p == nil || p.Email == "" {
|
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||||
continue
|
|
||||||
}
|
|
||||||
firstName := p.PreferredName
|
|
||||||
if firstName == "" {
|
|
||||||
firstName = tk.Name
|
|
||||||
}
|
|
||||||
if parts := strings.Fields(firstName); len(parts) > 0 {
|
|
||||||
firstName = parts[0]
|
firstName = parts[0]
|
||||||
}
|
}
|
||||||
link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code)
|
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||||
wr.Write([]string{p.Email, firstName, *tk.Code, link})
|
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||||
}
|
}
|
||||||
wr.Flush()
|
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) {
|
func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.Atoi(r.PathValue("id"))
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tk, err := app.getTicket(id)
|
a, err := app.getAttendee(id)
|
||||||
if err != nil || tk == nil {
|
if err != nil || a == nil {
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
writeError(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := app.sendTicketTokenEmail(*tk); err != nil {
|
if err := app.sendTokenEmail(*a); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{"ok": true})
|
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) {
|
func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
tickets, err := app.listTickets(nil, "")
|
attendees, err := app.listAttendees("", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var sent, skipped int
|
var sent, skipped int
|
||||||
var errors []string
|
var errors []string
|
||||||
for _, tk := range tickets {
|
for _, a := range attendees {
|
||||||
if tk.Code == nil || tk.ParticipantID == nil {
|
if a.Email == "" || a.VolunteerToken == nil {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := app.sendTicketTokenEmail(tk); err != nil {
|
if err := app.sendTokenEmail(a); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err))
|
errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err))
|
||||||
skipped++
|
skipped++
|
||||||
} else {
|
} else {
|
||||||
sent++
|
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) {
|
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
PreferredName string `json:"preferred_name"`
|
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Roles []string `json:"roles"`
|
Role string `json:"role"`
|
||||||
DepartmentIDs []int `json:"department_ids"`
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, "invalid request", http.StatusBadRequest)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Email == "" || body.Password == "" || len(body.Roles) == 0 {
|
if body.Username == "" || body.Password == "" || body.Role == "" {
|
||||||
writeError(w, "email, password, and at least one role are required", http.StatusBadRequest)
|
writeError(w, "username, password, and role are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := hashPassword(body.Password)
|
hash, err := hashPassword(body.Password)
|
||||||
|
|
@ -39,7 +38,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if body.DepartmentIDs == nil {
|
if body.DepartmentIDs == nil {
|
||||||
body.DepartmentIDs = []int{}
|
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 {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -54,13 +53,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target, _ := app.getUser(id)
|
|
||||||
if target == nil {
|
|
||||||
writeError(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Roles []string `json:"roles"`
|
Role string `json:"role"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
DepartmentIDs []int `json:"department_ids"`
|
DepartmentIDs []int `json:"department_ids"`
|
||||||
}
|
}
|
||||||
|
|
@ -71,8 +65,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
if body.DepartmentIDs == nil {
|
if body.DepartmentIDs == nil {
|
||||||
body.DepartmentIDs = []int{}
|
body.DepartmentIDs = []int{}
|
||||||
}
|
}
|
||||||
if body.Roles != nil {
|
if body.Role != "" {
|
||||||
if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil {
|
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +82,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user, _ := app.getUser(id)
|
user, _ := app.getUserByID(id)
|
||||||
writeJSON(w, user)
|
writeJSON(w, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,11 +93,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if claims.ParticipantID == id {
|
if claims.UserID == id {
|
||||||
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := app.removeUser(id); err != nil {
|
if err := app.deleteUser(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
@ -12,19 +11,20 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
||||||
search := q.Get("search")
|
search := q.Get("search")
|
||||||
since := q.Get("since")
|
since := q.Get("since")
|
||||||
|
|
||||||
var deptIDs []int
|
var deptID *int
|
||||||
if d := q.Get("dept"); d != "" {
|
if d := q.Get("dept"); d != "" {
|
||||||
if id, err := strconv.Atoi(d); err == nil {
|
id, err := strconv.Atoi(d)
|
||||||
deptIDs = []int{id}
|
if err == nil {
|
||||||
|
deptID = &id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||||
deptIDs = claims.DeptIDs
|
deptID = &claims.DeptIDs[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
volunteers, err := app.listVolunteers(search, deptIDs, since)
|
volunteers, err := app.listVolunteers(search, deptID, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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) {
|
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var v Volunteer
|
||||||
Name string `json:"name"`
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
TicketName string `json:"ticket_name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
DepartmentID *int `json:"department_id"`
|
|
||||||
IsLead bool `json:"is_lead"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, "invalid request", http.StatusBadRequest)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Name == "" {
|
if v.Name == "" {
|
||||||
writeError(w, "name is required", http.StatusBadRequest)
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Email == "" {
|
|
||||||
writeError(w, "email is required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) {
|
if claims.Role == "volunteer_lead" {
|
||||||
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
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)
|
created, err := app.createVolunteer(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
writeJSON(w, created)
|
writeJSON(w, created)
|
||||||
}
|
}
|
||||||
|
|
@ -116,40 +78,28 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var v Volunteer
|
||||||
DepartmentID *int `json:"department_id"`
|
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||||
IsLead bool `json:"is_lead"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, "invalid request", http.StatusBadRequest)
|
writeError(w, "invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if v.Name == "" {
|
||||||
|
writeError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) {
|
if claims.Role == "volunteer_lead" {
|
||||||
existing, _ := app.getVolunteer(id)
|
existing, _ := app.getVolunteer(id)
|
||||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||||
return
|
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 {
|
if err := app.updateVolunteer(v); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if v.IsLead {
|
|
||||||
app.confirmVolunteer(id)
|
|
||||||
}
|
|
||||||
updated, _ := app.getVolunteer(id)
|
updated, _ := app.getVolunteer(id)
|
||||||
writeJSON(w, updated)
|
writeJSON(w, updated)
|
||||||
}
|
}
|
||||||
|
|
@ -160,14 +110,6 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err := app.deleteVolunteer(id); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -175,21 +117,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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"))
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, "invalid id", http.StatusBadRequest)
|
writeError(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
claims := claimsFromContext(r)
|
||||||
if isCoLeadOnly(claims) {
|
v, err := app.checkInVolunteer(id, claims.UserID)
|
||||||
v, _ := app.getVolunteer(id)
|
|
||||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v, err := app.markVolunteerReady(id, claims.ParticipantID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -198,28 +133,6 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
|
||||||
writeJSON(w, v)
|
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) {
|
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -233,24 +146,7 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "shift_id required", http.StatusBadRequest)
|
writeError(w, "shift_id required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
claims := claimsFromContext(r)
|
if err := app.assignShift(volunteerID, body.ShiftID); err != nil {
|
||||||
if isCoLeadOnly(claims) {
|
|
||||||
v, _ := app.getVolunteer(volunteerID)
|
|
||||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
|
||||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shift, err := app.getShift(body.ShiftID)
|
|
||||||
if err != nil || shift == nil {
|
|
||||||
writeError(w, "shift not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil {
|
|
||||||
if err == errShiftFull {
|
|
||||||
writeError(w, "shift is at capacity", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -268,14 +164,6 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, "invalid shift id", http.StatusBadRequest)
|
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -283,3 +171,11 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
main.go
92
main.go
|
|
@ -12,8 +12,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var buildID = "dev"
|
|
||||||
|
|
||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
var frontendFS embed.FS
|
var frontendFS embed.FS
|
||||||
|
|
||||||
|
|
@ -99,44 +97,39 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper"))
|
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
|
||||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin"))
|
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin"))
|
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||||
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper"))
|
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||||
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin"))
|
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||||
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin"))
|
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||||
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin"))
|
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("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper"))
|
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||||
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin"))
|
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
|
||||||
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper"))
|
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin"))
|
|
||||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin"))
|
|
||||||
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin"))
|
|
||||||
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin"))
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing"))
|
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
|
||||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing"))
|
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
|
||||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead"))
|
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead"))
|
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead"))
|
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead"))
|
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
|
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
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", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead"))
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead"))
|
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead"))
|
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead"))
|
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead"))
|
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead"))
|
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
|
||||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead"))
|
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", "staffing", "colead"))
|
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("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||||
|
|
@ -146,31 +139,12 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "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/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/pull", auth(app.handleSyncPull))
|
||||||
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, map[string]string{"build": buildID})
|
|
||||||
})
|
|
||||||
|
|
||||||
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.
|
// Kiosk — authenticated by volunteer token, no JWT required.
|
||||||
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
||||||
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
||||||
|
|
@ -199,9 +173,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) bootstrapAdmin() error {
|
func (app *App) bootstrapAdmin() error {
|
||||||
adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL")
|
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
|
||||||
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
||||||
if adminEmail == "" || adminPass == "" {
|
if adminUser == "" || adminPass == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
n, err := app.countUsers()
|
n, err := app.countUsers()
|
||||||
|
|
@ -212,11 +186,11 @@ func (app *App) bootstrapAdmin() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{})
|
_, err = app.createUser(adminUser, hash, "admin", []int{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("Created admin user: %s", adminEmail)
|
log.Printf("Created admin user: %s", adminUser)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testApp(t *testing.T) *App {
|
|
||||||
t.Helper()
|
|
||||||
db, err := initDB(":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
|
|
||||||
return &App{
|
|
||||||
db: db,
|
|
||||||
secret: "test-secret",
|
|
||||||
tokenExpiry: 24,
|
|
||||||
broker: newBroker(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAdminUser(t *testing.T, app *App) *User {
|
|
||||||
t.Helper()
|
|
||||||
hash, _ := hashPassword("admin123")
|
|
||||||
u, err := app.createUser("oberon@athens.example", "Oberon", hash, []string{"admin"}, []int{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
func testUserWithRoles(t *testing.T, app *App, name string, roles []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)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
func testToken(t *testing.T, app *App, user *User) string {
|
|
||||||
t.Helper()
|
|
||||||
token, err := app.signToken(user)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMux(app *App) *http.ServeMux {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
app.registerRoutes(mux)
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRequest(method, path string, body any) *http.Request {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if body != nil {
|
|
||||||
json.NewEncoder(&buf).Encode(body)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(method, path, &buf)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAuthRequest(method, path string, body any, token string) *http.Request {
|
|
||||||
req := testRequest(method, path, body)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(i int) string {
|
|
||||||
return fmt.Sprintf("%d", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJSON(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
||||||
t.Fatalf("parse JSON: %v\nbody: %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue