Compare commits
54 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d64e93674e | |||
| d73a74965d | |||
| 6d4c49a223 | |||
| 374316944e | |||
| faa359751d | |||
| dc08723500 | |||
| 54da04763f | |||
| 5527c1eb91 | |||
| ad8c3a64b6 | |||
| 7dbcd05262 | |||
| da5f3524fa | |||
| 1eb6a99ff6 | |||
| e640bf8bed | |||
| 7d56ef2f33 | |||
| fcf5bf1f34 | |||
| e7e542c03c | |||
| 3429ee92fe | |||
| c03498b59e | |||
| 4c462c9d47 | |||
| e722ef055e | |||
| cc4dd76438 | |||
| ab3d9a0409 | |||
| 3eec81af7f | |||
| 72b245d6d6 | |||
| 62b3dece84 | |||
| d439306657 | |||
| 07f7d3d245 | |||
| 87da9cf97f | |||
| 2b409c65c1 | |||
| 6c21efcb16 | |||
| fa7ea35fd5 | |||
| 4d3da023fc | |||
| a60ef7d25b | |||
| 6eb72c5091 | |||
| 940cf29d04 | |||
| ecfbfcd53e | |||
| e7b25ea0c6 | |||
| 260e017f79 | |||
| 64ce97c74d | |||
| 219acb62d6 | |||
| 3906b73c61 | |||
| d30ee18e77 | |||
| cd8e1e3b3b | |||
| 0df93e1886 | |||
| f30b84aa3a | |||
| 4bba0ed3a0 | |||
| 8dc5d3ed01 | |||
| ace7f11a60 | |||
| 29a96879a8 | |||
| c79985312d | |||
| 4461adfa7a | |||
| ec5b5192da | |||
| f9c4facad6 | |||
| 9d0fa1f0af |
65 changed files with 7580 additions and 1760 deletions
27
Makefile
27
Makefile
|
|
@ -1,16 +1,37 @@
|
|||
.PHONY: build frontend-build dev clean
|
||||
.PHONY: build frontend-build dev clean test patch minor major
|
||||
|
||||
LAST_TAG := $(shell git tag --sort=-v:refname | head -1)
|
||||
MAJOR := $(shell echo $(LAST_TAG) | cut -d. -f1)
|
||||
MINOR := $(shell echo $(LAST_TAG) | cut -d. -f2)
|
||||
PATCH := $(shell echo $(LAST_TAG) | cut -d. -f3)
|
||||
|
||||
build: frontend-build
|
||||
CGO_ENABLED=0 go build -o turnpike .
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.buildID=$$(git rev-parse --short HEAD)" -o turnpike .
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm ci && npm run build
|
||||
cd frontend && npm ci && BUILD_ID=$$(git rev-parse --short HEAD) npm run build
|
||||
|
||||
dev:
|
||||
@echo "Run in two terminals:"
|
||||
@echo " Terminal 1: go run . --db dev.db"
|
||||
@echo " Terminal 2: cd frontend && npm run dev"
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd frontend && npx vitest run
|
||||
|
||||
clean:
|
||||
rm -f turnpike dev.db
|
||||
rm -rf frontend/dist
|
||||
|
||||
patch:
|
||||
git tag $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))
|
||||
@echo "Tagged $(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1)))"
|
||||
|
||||
minor:
|
||||
git tag $(MAJOR).$(shell echo $$(($(MINOR)+1))).0
|
||||
@echo "Tagged $(MAJOR).$(shell echo $$(($(MINOR)+1))).0"
|
||||
|
||||
major:
|
||||
git tag $(shell echo $$(($(MAJOR)+1))).0.0
|
||||
@echo "Tagged $(shell echo $$(($(MAJOR)+1))).0.0"
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -1,20 +1,21 @@
|
|||
# Turnpike
|
||||
|
||||
Self-hosted event attendee and volunteer management. One instance, one event.
|
||||
Self-hosted event ticketing and volunteer management. One instance, one event.
|
||||
|
||||
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
|
||||
|
||||
## Features
|
||||
|
||||
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
|
||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
|
||||
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
|
||||
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
|
||||
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
|
||||
- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
|
||||
- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
|
||||
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, reordering
|
||||
- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
|
||||
- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling
|
||||
- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers
|
||||
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
|
||||
- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
|
||||
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
|
||||
- **Real-time** — check-ins and changes broadcast live via SSE
|
||||
- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms
|
||||
- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open
|
||||
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
|
||||
|
||||
## Tech Stack
|
||||
|
|
@ -59,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
|
|||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
|
||||
| `coordinator` | All departments: volunteers, shifts, schedule board. No user management or settings |
|
||||
| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
|
||||
| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
|
||||
| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts |
|
||||
| `ticketing` | Participants, tickets, import. No user management |
|
||||
| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
|
||||
| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
|
||||
| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages |
|
||||
|
||||
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
|
|||
|
||||
## Documentation
|
||||
|
||||
- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board
|
||||
- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule
|
||||
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
|
||||
|
||||
## License
|
||||
|
|
|
|||
40
auth.go
40
auth.go
|
|
@ -12,9 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int `json:"uid"`
|
||||
Username string `json:"sub"`
|
||||
Role string `json:"role"`
|
||||
ParticipantID int `json:"pid"`
|
||||
Email string `json:"sub"`
|
||||
Roles []string `json:"roles"`
|
||||
DeptIDs []int `json:"dept_ids,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
|
@ -28,13 +28,13 @@ func checkPassword(hash, password string) bool {
|
|||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (app *App) signToken(u *User) (string, error) {
|
||||
func (app *App) signToken(s *User) (string, error) {
|
||||
expiry := time.Duration(app.tokenExpiry) * time.Hour
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
DeptIDs: u.DepartmentIDs,
|
||||
ParticipantID: s.ID,
|
||||
Email: s.Email,
|
||||
Roles: s.Roles,
|
||||
DeptIDs: s.DepartmentIDs,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
|
|
@ -88,7 +88,7 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
|
|||
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if len(roles) > 0 && !hasRole(claims.Role, roles) {
|
||||
if len(roles) > 0 && !hasAnyRole(claims.Roles, roles) {
|
||||
writeError(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -97,9 +97,25 @@ func (app *App) requireAuth(next http.HandlerFunc, roles ...string) http.Handler
|
|||
}
|
||||
}
|
||||
|
||||
func hasRole(role string, allowed []string) bool {
|
||||
for _, r := range allowed {
|
||||
if r == role {
|
||||
func hasAnyRole(roles []string, allowed []string) bool {
|
||||
for _, r := range roles {
|
||||
for _, a := range allowed {
|
||||
if r == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isCoLeadOnly(claims *Claims) bool {
|
||||
return hasAnyRole(claims.Roles, []string{"colead"}) &&
|
||||
!hasAnyRole(claims.Roles, []string{"admin", "staffing"})
|
||||
}
|
||||
|
||||
func inSlice(v int, s []int) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
auth_test.go
Normal file
126
auth_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginValid(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": admin.Email,
|
||||
"password": "admin123",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200\nbody: %s", w.Code, w.Body.String())
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["token"] == nil || result["token"] == "" {
|
||||
t.Error("missing token in response")
|
||||
}
|
||||
user, ok := result["user"].(map[string]any)
|
||||
if !ok || user["email"] != "oberon@athens.example" {
|
||||
t.Errorf("user = %v", result["user"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
app := testApp(t)
|
||||
testAdminUser(t, app)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": "oberon@athens.example",
|
||||
"password": "wrong",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNonexistentUser(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testRequest("POST", "/api/login", map[string]string{
|
||||
"email": "nobody@test.com",
|
||||
"password": "test",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareNoToken(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testRequest("GET", "/api/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareInvalidToken(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testAuthRequest("GET", "/api/me", nil, "bad-token")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareRoleEnforcement(t *testing.T) {
|
||||
app := testApp(t)
|
||||
mux := testMux(app)
|
||||
|
||||
gate := testUserWithRoles(t, app, "Starveling", []string{"gatekeeper"}, []int{})
|
||||
token := testToken(t, app, gate)
|
||||
|
||||
req := testAuthRequest("GET", "/api/users", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeEndpoint(t *testing.T) {
|
||||
app := testApp(t)
|
||||
admin := testAdminUser(t, app)
|
||||
token := testToken(t, app, admin)
|
||||
mux := testMux(app)
|
||||
|
||||
req := testAuthRequest("GET", "/api/me", nil, token)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
result := parseJSON(t, w)
|
||||
if result["email"] != "oberon@athens.example" {
|
||||
t.Errorf("email = %v", result["email"])
|
||||
}
|
||||
}
|
||||
186
db_test.go
Normal file
186
db_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
app := testApp(t)
|
||||
// Verify tables exist by querying each one
|
||||
tables := []string{"event", "participants", "participant_roles", "departments", "volunteers", "shifts", "volunteer_shifts"}
|
||||
for _, table := range tables {
|
||||
var count int
|
||||
err := app.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
|
||||
if err != nil {
|
||||
t.Errorf("table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(token) != 8 {
|
||||
t.Errorf("token length = %d, want 8", len(token))
|
||||
}
|
||||
for _, c := range token {
|
||||
if !isValidTokenChar(c) {
|
||||
t.Errorf("invalid char %c in token %s", c, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isValidTokenChar(c rune) bool {
|
||||
for _, tc := range tokenChars {
|
||||
if c == tc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestGenerateUniqueToken(t *testing.T) {
|
||||
app := testApp(t)
|
||||
token, err := app.generateUniqueToken()
|
||||
if err != nil || len(token) != 8 {
|
||||
t.Fatalf("token=%q, err=%v", token, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepartmentsCRUD(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
d, err := app.createDepartment(Department{Name: "Gate"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Name != "Gate" {
|
||||
t.Errorf("name = %q", d.Name)
|
||||
}
|
||||
|
||||
depts, _ := app.listDepartments("")
|
||||
if len(depts) != 1 {
|
||||
t.Errorf("list: got %d", len(depts))
|
||||
}
|
||||
|
||||
if err := app.deleteDepartment(d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShiftsCRUD(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
s, err := app.createShift(Shift{
|
||||
DepartmentID: dept.ID,
|
||||
Name: "Morning",
|
||||
Day: "2026-03-15",
|
||||
StartTime: "08:00",
|
||||
EndTime: "12:00",
|
||||
Capacity: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Name != "Morning" || s.Capacity != 5 {
|
||||
t.Errorf("create: %+v", s)
|
||||
}
|
||||
|
||||
got, _ := app.getShift(s.ID)
|
||||
if got == nil || got.Day != "2026-03-15" {
|
||||
t.Error("get: not found or wrong day")
|
||||
}
|
||||
|
||||
if err := app.deleteShift(s.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignAndUnassignShift(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
s, _ := app.createShift(Shift{DepartmentID: deptID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00", Capacity: 2})
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Helena", Email: "helena@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
|
||||
if err := app.assignShift(v.ID, s.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, _ := app.shiftAssignedCount(s.ID)
|
||||
if count != 1 {
|
||||
t.Errorf("assigned count = %d, want 1", count)
|
||||
}
|
||||
|
||||
if err := app.unassignShift(v.ID, s.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, _ = app.shiftAssignedCount(s.ID)
|
||||
if count != 0 {
|
||||
t.Errorf("after unassign: count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckShiftConflict(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Gate"})
|
||||
deptID := dept.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Hermia", Email: "hermia@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
|
||||
s1, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
s2, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Overlap", Day: "2026-03-15", StartTime: "10:00", EndTime: "14:00"})
|
||||
s3, _ := app.createShift(Shift{DepartmentID: deptID, Name: "NoOverlap", Day: "2026-03-15", StartTime: "14:00", EndTime: "18:00"})
|
||||
|
||||
app.assignShift(v.ID, s1.ID)
|
||||
|
||||
// s2 overlaps s1 (10:00-14:00 vs 08:00-12:00)
|
||||
conflicts, err := app.checkShiftConflict(v.ID, s2.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Errorf("overlap: got %d conflicts, want 1", len(conflicts))
|
||||
}
|
||||
|
||||
// s3 does not overlap s1 (14:00-18:00 vs 08:00-12:00)
|
||||
conflicts, _ = app.checkShiftConflict(v.ID, s3.ID)
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("no overlap: got %d conflicts, want 0", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckShiftConflictMidnight(t *testing.T) {
|
||||
app := testApp(t)
|
||||
|
||||
dept, _ := app.createDepartment(Department{Name: "Sound"})
|
||||
deptID := dept.ID
|
||||
p, _ := app.createParticipant(Participant{PreferredName: "Lysander", Email: "lysander@test.com"})
|
||||
v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptID})
|
||||
|
||||
// Night shift: 22:00-02:00 (spans midnight)
|
||||
night, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Night", Day: "2026-03-15", StartTime: "22:00", EndTime: "02:00"})
|
||||
// Late shift: 23:00-03:00 (overlaps with night)
|
||||
late, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Late", Day: "2026-03-15", StartTime: "23:00", EndTime: "03:00"})
|
||||
// Morning shift: 08:00-12:00 (no overlap with night)
|
||||
morning, _ := app.createShift(Shift{DepartmentID: deptID, Name: "Morning", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
|
||||
|
||||
app.assignShift(v.ID, night.ID)
|
||||
|
||||
// Late should conflict with night
|
||||
conflicts, _ := app.checkShiftConflict(v.ID, late.ID)
|
||||
if len(conflicts) != 1 {
|
||||
t.Errorf("midnight overlap: got %d conflicts, want 1", len(conflicts))
|
||||
}
|
||||
|
||||
// Morning should not conflict with night
|
||||
conflicts, _ = app.checkShiftConflict(v.ID, morning.ID)
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("no midnight overlap: got %d conflicts, want 0", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
|
@ -105,23 +105,27 @@ docker run -p 8180:8180 \
|
|||
|
||||
## NixOS
|
||||
|
||||
Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO):
|
||||
Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build:
|
||||
|
||||
```nix
|
||||
frontendDist = pkgs.buildNpmPackage {
|
||||
pname = "turnpike-frontend";
|
||||
src = "${src}/frontend";
|
||||
npmDepsHash = "sha256-...";
|
||||
buildPhase = "npm run build";
|
||||
installPhase = "cp -r dist $out";
|
||||
};
|
||||
|
||||
turnpike = pkgs.buildGoModule {
|
||||
pname = "turnpike";
|
||||
version = "0.1.0";
|
||||
src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
|
||||
vendorHash = null;
|
||||
src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
|
||||
vendorHash = "sha256-...";
|
||||
env.CGO_ENABLED = 0;
|
||||
preBuild = "cp -r ${frontendDist} frontend/dist";
|
||||
};
|
||||
```
|
||||
|
||||
The source directory must contain:
|
||||
- Go source files and `vendor/` (run `go mod vendor`)
|
||||
- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`)
|
||||
|
||||
A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`.
|
||||
A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
|
|
|
|||
142
docs/USAGE.md
142
docs/USAGE.md
|
|
@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets
|
|||
|
||||
| Role | What they see | What they can do |
|
||||
|------|--------------|------------------|
|
||||
| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
|
||||
| **coordinator** | Dashboard, Schedule Board, Volunteers, Departments, Shifts | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
|
||||
| **volunteer_lead** | Schedule Board, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
|
||||
| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
|
||||
| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers |
|
||||
| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
|
||||
| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
|
||||
| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
|
||||
| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages |
|
||||
|
||||
Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
|
||||
|
||||
Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
|
||||
Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
|
||||
|
||||
## Event Setup
|
||||
|
||||
1. **Configure your event** — go to the Dashboard and set the event name and dates.
|
||||
1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page.
|
||||
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
|
||||
3. **Import attendees** — see next section.
|
||||
4. **Create shifts** — under Shifts, create shifts for each department with day, start/end time, and capacity.
|
||||
3. **Import participants** — see next section.
|
||||
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
|
||||
|
||||
## Importing Attendees
|
||||
## Importing Participants
|
||||
|
||||
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
|||
|
||||
| Column | Maps to |
|
||||
|--------|---------|
|
||||
| `Patron Name` | Name |
|
||||
| `Patron Name` | Ticket name |
|
||||
| `Patron Email` | Email |
|
||||
| `Order Number` | Ticket ID |
|
||||
| `Tier Name` | Ticket type |
|
||||
|
|
@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
|||
|
||||
| Column | Maps to |
|
||||
|--------|---------|
|
||||
| `name` (required) | Name |
|
||||
| `name` (required) | Ticket name |
|
||||
| `email` | Email |
|
||||
| `ticket_id` | Ticket ID |
|
||||
| `ticket_type` | Ticket type |
|
||||
|
|
@ -53,32 +52,67 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
|
|||
|
||||
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
|
||||
|
||||
### Party-size dedup
|
||||
### Participants and tickets
|
||||
|
||||
CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically:
|
||||
Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates).
|
||||
|
||||
- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1`
|
||||
- Subsequent rows with the same name + order number increment `party_size` (no duplicate record)
|
||||
- Result: one attendee record, `party_size=3` if three tickets were purchased
|
||||
Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated.
|
||||
|
||||
The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates).
|
||||
## Volunteer Signup
|
||||
|
||||
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
|
||||
Turnpike provides a public signup form for volunteers at `/volunteer-signup`. No login is required.
|
||||
|
||||
### Signup flow
|
||||
|
||||
1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
|
||||
2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant record.
|
||||
3. A confirmation email is sent with a unique link (`/confirm/{token}`).
|
||||
4. The volunteer clicks the link to confirm their email.
|
||||
5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
|
||||
|
||||
Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration.
|
||||
|
||||
### Configuring the signup form
|
||||
|
||||
In **Settings**, the "Volunteer Signup" card controls:
|
||||
|
||||
- **Note field label** — customize the label shown on the form (default: "Additional note")
|
||||
- **Note field required** — when checked, volunteers must fill in the note to submit
|
||||
|
||||
### Opening shift signups
|
||||
|
||||
In **Settings**, the "Shift Signups" card has an open/close toggle:
|
||||
|
||||
- **Opening** signups generates kiosk codes for all registered (email-confirmed) volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
|
||||
- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
|
||||
|
||||
If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
|
||||
|
||||
## Managing Volunteers
|
||||
|
||||
Under **Volunteers**, you can:
|
||||
|
||||
- Create volunteers manually (name, email, department)
|
||||
- Link a volunteer to an existing attendee record (for dual check-in at the gate)
|
||||
- Assign volunteers to departments
|
||||
- Check in volunteers
|
||||
- Create volunteers manually (name, email, department, co-lead, note)
|
||||
- Edit existing volunteers (department, co-lead, note) via the inline Edit button
|
||||
- Confirm registered volunteers (admin, staffing, colead)
|
||||
- Mark volunteers as ready (briefed at the volunteer station)
|
||||
|
||||
Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously.
|
||||
### Volunteer statuses
|
||||
|
||||
| Status | Meaning | Who sets it |
|
||||
|--------|---------|-------------|
|
||||
| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
|
||||
| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
|
||||
| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead |
|
||||
| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in |
|
||||
|
||||
**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them.
|
||||
|
||||
Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
|
||||
|
||||
## Shift Scheduling
|
||||
|
||||
Under **Shifts**, create shifts for each department:
|
||||
Under **Schedule**, create shifts for each department:
|
||||
|
||||
- **Day** — the date of the shift
|
||||
- **Start/end time** — HH:MM format
|
||||
|
|
@ -86,27 +120,29 @@ Under **Shifts**, create shifts for each department:
|
|||
|
||||
### Assigning volunteers
|
||||
|
||||
From the Shifts page or the Schedule Board, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment.
|
||||
From the Schedule page, assign volunteers to shifts. Turnpike checks for conflicts — if a volunteer already has a shift on the same day with overlapping times, you'll see a warning and can choose to force the assignment.
|
||||
|
||||
### Reordering
|
||||
|
||||
Shifts can be reordered within a department to reflect priority or sequence. The Schedule Board supports drag-and-drop reordering.
|
||||
Shifts can be reordered within a department to reflect priority or sequence using the up/down buttons on each shift card.
|
||||
|
||||
## Volunteer Kiosk
|
||||
|
||||
The kiosk lets volunteers self-select shifts without logging in.
|
||||
The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one.
|
||||
2. **Distribute tokens** — two options:
|
||||
- **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
|
||||
- **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
|
||||
3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
|
||||
Kiosk links are generated and distributed automatically through the volunteer signup flow:
|
||||
|
||||
1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
|
||||
2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending.
|
||||
3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately.
|
||||
|
||||
**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL.
|
||||
|
||||
### Volunteer experience
|
||||
|
||||
Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing:
|
||||
Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing:
|
||||
|
||||
- Their name and department
|
||||
- Currently assigned shifts
|
||||
|
|
@ -114,43 +150,45 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`.
|
|||
|
||||
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
|
||||
|
||||
No login is required. The 8-character token authenticates the request.
|
||||
No login is required. The kiosk code authenticates the request.
|
||||
|
||||
### Token format
|
||||
### Code format
|
||||
|
||||
Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
||||
Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
|
||||
|
||||
## Gate Check-In
|
||||
## Gate Kiosk
|
||||
|
||||
Users with the **gate** role see a dedicated full-screen UI:
|
||||
Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk:
|
||||
|
||||
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
|
||||
- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline).
|
||||
- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining."
|
||||
- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously.
|
||||
- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
|
||||
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
|
||||
|
||||
Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets.
|
||||
|
||||
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
|
||||
|
||||
## Schedule Board
|
||||
## Schedule
|
||||
|
||||
The Schedule Board is the primary UI for coordinators and volunteer leads. It shows:
|
||||
The Schedule page is the primary UI for managing shifts and volunteer assignments. It shows:
|
||||
|
||||
- Shifts grouped by department and day
|
||||
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
|
||||
- Conflict badges when a volunteer has overlapping shifts on the same day
|
||||
|
||||
**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
|
||||
**Admins and staffing** see all departments. **Coleads** see only their assigned department(s).
|
||||
|
||||
Actions available:
|
||||
- Create new shifts (+ Add shift button)
|
||||
- Edit shift details inline
|
||||
- Delete shifts
|
||||
- Assign volunteers to shifts from a dropdown
|
||||
- Remove volunteer assignments
|
||||
- Reorder shifts within a department
|
||||
- Edit shift details inline
|
||||
|
||||
## SMTP Configuration
|
||||
|
||||
SMTP enables token email distribution and test emails. Configure in **Settings** (admin only):
|
||||
SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only):
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
|
|
@ -171,13 +209,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline:
|
|||
|
||||
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
|
||||
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
|
||||
- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order.
|
||||
- **Sync** pulls all changes from the server on startup and periodically thereafter.
|
||||
|
||||
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
|
||||
|
||||
## CSV Exports
|
||||
|
||||
Two CSV exports are available from the Attendees page:
|
||||
CSV exports are available from the Participants page:
|
||||
|
||||
- **Attendee export** — all attendee records with check-in status
|
||||
- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns.
|
||||
- **Participant export** — all participant records with check-in status
|
||||
- **Ticket export** — all ticket records with codes and check-in status
|
||||
|
|
|
|||
72
email.go
72
email.go
|
|
@ -106,35 +106,73 @@ func sendEmail(cfg SMTPConfig, to, subject, body string) error {
|
|||
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
|
||||
}
|
||||
|
||||
// sendTokenEmail sends a volunteer token link to the attendee's email address.
|
||||
func (app *App) sendTokenEmail(a Attendee) error {
|
||||
if a.Email == "" {
|
||||
return fmt.Errorf("attendee has no email address")
|
||||
}
|
||||
if a.VolunteerToken == nil || *a.VolunteerToken == "" {
|
||||
return fmt.Errorf("attendee has no volunteer token")
|
||||
}
|
||||
|
||||
cfg := app.loadSMTPConfig()
|
||||
|
||||
func (app *App) resolveBaseURL() string {
|
||||
baseURL := app.baseURL
|
||||
if baseURL == "" {
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&baseURL)
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
return strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (app *App) eventName() string {
|
||||
event, _ := app.getEvent()
|
||||
eventName := "the event"
|
||||
if event != nil && event.Name != "" {
|
||||
eventName = event.Name
|
||||
return event.Name
|
||||
}
|
||||
return "the event"
|
||||
}
|
||||
|
||||
// sendTicketTokenEmail sends a volunteer token link for a ticket to its participant's email.
|
||||
func (app *App) sendTicketTokenEmail(tk Ticket) error {
|
||||
if tk.Code == nil || *tk.Code == "" {
|
||||
return fmt.Errorf("ticket has no code")
|
||||
}
|
||||
if tk.ParticipantID == nil {
|
||||
return fmt.Errorf("ticket has no participant")
|
||||
}
|
||||
p, err := app.getParticipant(*tk.ParticipantID)
|
||||
if err != nil || p == nil {
|
||||
return fmt.Errorf("participant not found")
|
||||
}
|
||||
if p.Email == "" {
|
||||
return fmt.Errorf("participant has no email address")
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||
cfg := app.loadSMTPConfig()
|
||||
eventName := app.eventName()
|
||||
link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *tk.Code)
|
||||
name := p.PreferredName
|
||||
if name == "" {
|
||||
name = tk.Name
|
||||
}
|
||||
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
|
||||
body := fmt.Sprintf(
|
||||
"Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n",
|
||||
a.Name, eventName, *a.VolunteerToken, link,
|
||||
name, eventName, *tk.Code, link,
|
||||
)
|
||||
|
||||
return sendEmail(cfg, a.Email, subject, body)
|
||||
return sendEmail(cfg, p.Email, subject, body)
|
||||
}
|
||||
|
||||
func (app *App) sendConfirmationEmail(to, name, confirmToken string) error {
|
||||
cfg := app.loadSMTPConfig()
|
||||
eventName := app.eventName()
|
||||
link := fmt.Sprintf("%s/confirm/%s", app.resolveBaseURL(), confirmToken)
|
||||
subject := fmt.Sprintf("Please confirm your email for %s", eventName)
|
||||
body := fmt.Sprintf(
|
||||
"Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n",
|
||||
name, eventName, link,
|
||||
)
|
||||
return sendEmail(cfg, to, subject, body)
|
||||
}
|
||||
|
||||
func (app *App) sendShiftSignupEmail(to, name, kioskLink string) error {
|
||||
cfg := app.loadSMTPConfig()
|
||||
eventName := app.eventName()
|
||||
subject := fmt.Sprintf("Shift signups are open for %s!", eventName)
|
||||
body := fmt.Sprintf(
|
||||
"Hi %s,\n\nShift signups are now open for %s!\n\nUse this link to sign up for available shifts:\n%s\n\nSee you there!\n",
|
||||
name, eventName, kioskLink,
|
||||
)
|
||||
return sendEmail(cfg, to, subject, body)
|
||||
}
|
||||
|
|
|
|||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
|
@ -8,6 +8,7 @@ pnpm-debug.log*
|
|||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.vite
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
|
|
|||
|
|
@ -1,43 +1,34 @@
|
|||
# Svelte + Vite
|
||||
# Turnpike Frontend
|
||||
|
||||
This template should help get you started developing with Svelte in Vite.
|
||||
Svelte 5 + Vite PWA. Offline-first with Dexie (IndexedDB) and background sync.
|
||||
|
||||
## Recommended IDE Setup
|
||||
## Development
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||
From the repo root with `direnv allow` (or Node.js 18+ installed):
|
||||
|
||||
## Need an official Svelte framework?
|
||||
|
||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
**Why use this over SvelteKit?**
|
||||
|
||||
- It brings its own routing solution which might not be preferable for some users.
|
||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||
|
||||
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||
|
||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||
|
||||
**Why include `.vscode/extensions.json`?**
|
||||
|
||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||
|
||||
**Why enable `checkJs` in the JS template?**
|
||||
|
||||
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||
|
||||
**Why is HMR not preserving my local component state?**
|
||||
|
||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||
|
||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||
|
||||
```js
|
||||
// store.js
|
||||
// An extremely simple external store
|
||||
import { writable } from 'svelte/store'
|
||||
export default writable(0)
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Runs on `:5173`, proxies `/api` to the Go backend on `:8180`.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output goes to `dist/`, which the Go binary embeds at compile time.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/db.js` — Dexie schema, session management
|
||||
- `src/api.js` — all API calls, injects `Authorization: Bearer` header
|
||||
- `src/sync.js` — sync pull, SSE stream, outbox flush
|
||||
- `src/pages/` — page components (one per route)
|
||||
- `src/components/` — shared UI components
|
||||
- `src/app.css` — global CSS custom properties (colors, spacing, type scale)
|
||||
|
||||
All UI reads come from Dexie via `liveQuery()`, not direct API calls. Styles are scoped per component; no hardcoded color values.
|
||||
|
|
|
|||
977
frontend/package-lock.json
generated
977
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,14 +6,20 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^28.1.0",
|
||||
"svelte": "^5.45.2",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"dexie": "^4.3.0"
|
||||
"dexie": "^4.3.0",
|
||||
"lucide-svelte": "^0.576.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,164 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { getSession, clearSession } from './db.js'
|
||||
import { getSession, saveSession, clearSession } from './db.js'
|
||||
import { syncPull, startSSE, startSyncLoop } from './sync.js'
|
||||
import Login from './pages/Login.svelte'
|
||||
import Dashboard from './pages/Dashboard.svelte'
|
||||
import Attendees from './pages/Attendees.svelte'
|
||||
import Participants from './pages/Participants.svelte'
|
||||
import Volunteers from './pages/Volunteers.svelte'
|
||||
import Departments from './pages/Departments.svelte'
|
||||
import Shifts from './pages/Shifts.svelte'
|
||||
import Users from './pages/Users.svelte'
|
||||
import Import from './pages/Import.svelte'
|
||||
import Kiosk from './pages/Kiosk.svelte'
|
||||
import GateUI from './pages/GateUI.svelte'
|
||||
import VolunteerKiosk from './pages/VolunteerKiosk.svelte'
|
||||
import VolunteerSignup from './pages/VolunteerSignup.svelte'
|
||||
import ConfirmEmail from './pages/ConfirmEmail.svelte'
|
||||
import GateKiosk from './pages/GateKiosk.svelte'
|
||||
import ScheduleBoard from './pages/ScheduleBoard.svelte'
|
||||
import Settings from './pages/Settings.svelte'
|
||||
import Nav from './components/Nav.svelte'
|
||||
import SyncStatus from './components/SyncStatus.svelte'
|
||||
|
||||
const clientBuild = __BUILD_ID__
|
||||
|
||||
let session = $state(null)
|
||||
let loading = $state(true)
|
||||
let route = $state(window.location.hash || '#/')
|
||||
let route = $state(window.location.pathname)
|
||||
let updateAvailable = $state(false)
|
||||
let mobileNavOpen = $state(false)
|
||||
let ssoError = $state('')
|
||||
|
||||
// Check if this is a kiosk token URL before doing anything else
|
||||
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
// Check if this is a public page (no auth needed)
|
||||
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
|
||||
const isConfirmEmail = $derived(route.startsWith('/confirm/'))
|
||||
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
|
||||
|
||||
function navigate(path) {
|
||||
history.pushState(null, '', path)
|
||||
route = path
|
||||
mobileNavOpen = false
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
async function checkVersion() {
|
||||
try {
|
||||
const res = await fetch('/api/version')
|
||||
const { build } = await res.json()
|
||||
if (build && build !== clientBuild) updateAvailable = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Kiosk pages don't need auth
|
||||
if (kioskToken) {
|
||||
checkVersion()
|
||||
|
||||
// Public pages don't need auth
|
||||
if (isPublicPage) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Handle SSO callback in URL fragment
|
||||
const hash = window.location.hash
|
||||
if (hash.startsWith('#sso_token=')) {
|
||||
const token = decodeURIComponent(hash.slice('#sso_token='.length))
|
||||
history.replaceState(null, '', '/')
|
||||
try {
|
||||
const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
if (res.ok) {
|
||||
const user = await res.json()
|
||||
await saveSession(token, user)
|
||||
session = { token, user }
|
||||
} else {
|
||||
ssoError = 'SSO login failed. Please try again.'
|
||||
}
|
||||
} catch {
|
||||
ssoError = 'SSO login failed. Please try again.'
|
||||
}
|
||||
} else if (hash.startsWith('#sso_error=')) {
|
||||
ssoError = decodeURIComponent(hash.slice('#sso_error='.length))
|
||||
history.replaceState(null, '', '/')
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
session = await getSession()
|
||||
}
|
||||
loading = false
|
||||
if (session) {
|
||||
await syncPull()
|
||||
startSSE()
|
||||
startSyncLoop()
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
route = window.location.hash || '#/'
|
||||
window.addEventListener('popstate', () => {
|
||||
route = window.location.pathname
|
||||
mobileNavOpen = false
|
||||
})
|
||||
|
||||
// Periodically check for updates
|
||||
setInterval(checkVersion, 60000)
|
||||
})
|
||||
|
||||
function onLogin(s) {
|
||||
session = s
|
||||
window.location.hash = '#/'
|
||||
navigate('/')
|
||||
syncPull().then(() => { startSSE(); startSyncLoop() })
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await clearSession()
|
||||
session = null
|
||||
window.location.hash = '#/login'
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const path = $derived(route.replace(/^#/, '') || '/')
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const path = $derived(route || '/')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
</script>
|
||||
|
||||
{#if updateAvailable}
|
||||
<div class="update-banner">
|
||||
A new version is available.
|
||||
<button onclick={() => location.reload()}>Refresh</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<!-- checking session -->
|
||||
{:else if kioskToken}
|
||||
<Kiosk />
|
||||
<VolunteerKiosk />
|
||||
{:else if isVolunteerSignup}
|
||||
<VolunteerSignup />
|
||||
{:else if isConfirmEmail}
|
||||
<ConfirmEmail />
|
||||
{:else if !session}
|
||||
<Login onlogin={onLogin} />
|
||||
{:else if role === 'gate'}
|
||||
<!-- Gate users get the full-screen GateUI instead of the standard layout -->
|
||||
<GateUI {session} {onLogout} />
|
||||
<Login onlogin={onLogin} error={ssoError} />
|
||||
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
||||
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
||||
<GateKiosk {session} {onLogout} />
|
||||
{:else}
|
||||
<div class="layout">
|
||||
<Nav {session} {onLogout} active={path} />
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#if mobileNavOpen}
|
||||
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
|
||||
{/if}
|
||||
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
|
||||
<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 role === 'volunteer_lead'}
|
||||
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} />
|
||||
<Dashboard {session} {navigate} />
|
||||
{/if}
|
||||
{:else if path.startsWith('/attendees')}
|
||||
<Attendees {session} />
|
||||
{:else if path.startsWith('/participants')}
|
||||
<Participants {session} />
|
||||
{:else if path.startsWith('/volunteers')}
|
||||
<Volunteers {session} />
|
||||
{:else if path.startsWith('/departments')}
|
||||
<Departments {session} />
|
||||
{:else if path.startsWith('/shifts')}
|
||||
<Shifts {session} />
|
||||
{:else if path.startsWith('/schedule')}
|
||||
<ScheduleBoard {session} />
|
||||
{:else if path.startsWith('/users')}
|
||||
|
|
@ -100,3 +174,33 @@
|
|||
</div>
|
||||
</div>
|
||||
{/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 } from './db.js'
|
||||
import { db, clearSession } from './db.js'
|
||||
|
||||
async function getToken() {
|
||||
const session = await db.session.get(1)
|
||||
|
|
@ -17,8 +17,8 @@ export async function apiFetch(path, options = {}) {
|
|||
|
||||
const res = await fetch(path, { ...options, headers })
|
||||
if (res.status === 401) {
|
||||
await db.session.clear()
|
||||
window.location.hash = '#/login'
|
||||
await clearSession()
|
||||
window.location.pathname = '/login'
|
||||
throw new Error('unauthorized')
|
||||
}
|
||||
return res
|
||||
|
|
@ -48,28 +48,29 @@ async function kioskFetch(path, options = {}) {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
login: (username, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
login: (email, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||
me: () => apiJSON('/api/me'),
|
||||
event: {
|
||||
get: () => apiJSON('/api/event'),
|
||||
update: (data) => apiJSON('/api/event', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
},
|
||||
attendees: {
|
||||
list: (params = {}) => apiJSON('/api/attendees?' + new URLSearchParams(params)),
|
||||
get: (id) => apiJSON(`/api/attendees/${id}`),
|
||||
create: (data) => apiJSON('/api/attendees', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/attendees/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/attendees/${id}`, { method: 'DELETE' }),
|
||||
checkIn: (id, opts = {}) =>
|
||||
apiJSON(`/api/attendees/${id}/checkin`, { method: 'POST', body: JSON.stringify(opts) }),
|
||||
generateTokens: () =>
|
||||
apiJSON('/api/attendees/generate-tokens', { method: 'POST' }),
|
||||
emailToken: (id) =>
|
||||
apiJSON(`/api/attendees/${id}/email-token`, { method: 'POST' }),
|
||||
emailAllTokens: () =>
|
||||
apiJSON('/api/attendees/email-tokens', { method: 'POST' }),
|
||||
participants: {
|
||||
list: (params = {}) => apiJSON('/api/participants?' + new URLSearchParams(params)),
|
||||
get: (id) => apiJSON(`/api/participants/${id}`),
|
||||
create: (data) => apiJSON('/api/participants', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/participants/${id}`, { method: 'DELETE' }),
|
||||
merge: (id, otherId) => apiJSON(`/api/participants/${id}/merge/${otherId}`, { method: 'POST' }),
|
||||
},
|
||||
tickets: {
|
||||
list: () => apiJSON('/api/tickets'),
|
||||
create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }),
|
||||
checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }),
|
||||
generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }),
|
||||
emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }),
|
||||
emailAllCodes: () => apiJSON('/api/tickets/email-codes', { method: 'POST' }),
|
||||
},
|
||||
volunteers: {
|
||||
list: (params = {}) => apiJSON('/api/volunteers?' + new URLSearchParams(params)),
|
||||
|
|
@ -77,7 +78,8 @@ export const api = {
|
|||
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||
checkIn: (id) => apiJSON(`/api/volunteers/${id}/checkin`, { method: 'POST' }),
|
||||
markReady: (id) => apiJSON(`/api/volunteers/${id}/ready`, { method: 'POST' }),
|
||||
confirm: (id) => apiJSON(`/api/volunteers/${id}/confirm`, { method: 'POST' }),
|
||||
assignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts`, { method: 'POST', body: JSON.stringify({ shift_id: shiftId }) }),
|
||||
unassignShift: (id, shiftId) => apiFetch(`/api/volunteers/${id}/shifts/${shiftId}`, { method: 'DELETE' }),
|
||||
},
|
||||
|
|
@ -109,6 +111,21 @@ export const api = {
|
|||
get: () => apiJSON('/api/settings'),
|
||||
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
|
||||
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
|
||||
resetTickets: () => apiJSON('/api/settings/reset-tickets', { method: 'POST' }),
|
||||
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
|
||||
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
|
||||
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
|
||||
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
|
||||
},
|
||||
sso: {
|
||||
enabled: () => kioskFetch('/api/public/sso-enabled'),
|
||||
init: () => kioskFetch('/api/sso/init'),
|
||||
},
|
||||
signup: {
|
||||
config: () => kioskFetch('/api/public/signup-config'),
|
||||
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
|
||||
confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }),
|
||||
},
|
||||
import: async (formData) => {
|
||||
const res = await apiFetch('/api/import', { method: 'POST', body: formData })
|
||||
|
|
|
|||
145
frontend/src/api.test.js
Normal file
145
frontend/src/api.test.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { db, saveSession, clearSession } from './db.js'
|
||||
|
||||
// Must import api after fake-indexeddb is initialized (via test-setup.js)
|
||||
const { apiFetch, apiJSON, api } = await import('./api.js')
|
||||
|
||||
beforeEach(async () => {
|
||||
await Promise.all(db.tables.map(t => t.clear()))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function mockFetch(body = {}, status = 200) {
|
||||
const fn = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
})
|
||||
)
|
||||
globalThis.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('adds Authorization header when session exists', async () => {
|
||||
await saveSession('mytoken', { id: 1 })
|
||||
const f = mockFetch()
|
||||
await apiFetch('/api/test')
|
||||
expect(f).toHaveBeenCalledTimes(1)
|
||||
const [, opts] = f.mock.calls[0]
|
||||
expect(opts.headers['Authorization']).toBe('Bearer mytoken')
|
||||
})
|
||||
|
||||
it('omits Authorization when no session', async () => {
|
||||
const f = mockFetch()
|
||||
await apiFetch('/api/test')
|
||||
const [, opts] = f.mock.calls[0]
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears session on 401', async () => {
|
||||
await saveSession('expired', { id: 1 })
|
||||
mockFetch({}, 401)
|
||||
await expect(apiFetch('/api/test')).rejects.toThrow('unauthorized')
|
||||
expect(await db.session.get(1)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiJSON', () => {
|
||||
it('parses JSON response', async () => {
|
||||
mockFetch({ name: 'Titania' })
|
||||
const result = await apiJSON('/api/test')
|
||||
expect(result.name).toBe('Titania')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetch({ error: 'not found' }, 404)
|
||||
await expect(apiJSON('/api/test')).rejects.toThrow('not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api methods', () => {
|
||||
it('login calls correct endpoint', async () => {
|
||||
const f = mockFetch({ token: 'tok', user: { id: 1 } })
|
||||
await api.login('admin@example.com', 'pass')
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/login')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' })
|
||||
})
|
||||
|
||||
it('participants.list calls correct endpoint', async () => {
|
||||
const f = mockFetch({ participants: [] })
|
||||
await api.participants.list({ search: 'test' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants?search=test')
|
||||
})
|
||||
|
||||
it('participants.delete uses DELETE method', async () => {
|
||||
const f = mockFetch({}, 204)
|
||||
await api.participants.delete(5)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/participants/5')
|
||||
expect(f.mock.calls[0][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('sync.pull passes since param', async () => {
|
||||
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
|
||||
await api.sync.pull('2026-01-01T00:00:00Z')
|
||||
expect(f.mock.calls[0][0]).toContain('since=')
|
||||
})
|
||||
|
||||
it('sync.pull omits since when empty', async () => {
|
||||
const f = mockFetch({ server_time: '2026-01-01', attendees: [] })
|
||||
await api.sync.pull('')
|
||||
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
|
||||
})
|
||||
})
|
||||
|
||||
describe('signup methods', () => {
|
||||
it('signup.config fetches config without auth', async () => {
|
||||
const f = mockFetch({ departments: [], volunteer_note_label: 'Note' })
|
||||
await api.signup.config()
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/signup-config')
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.submit posts form data without auth', async () => {
|
||||
const f = mockFetch({ ok: true })
|
||||
await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/signup')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' })
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.confirm posts token without auth', async () => {
|
||||
const f = mockFetch({ status: 'confirmed' })
|
||||
await api.signup.confirm('abc123')
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/public/confirm')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' })
|
||||
expect(opts.headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('signup.submit throws on 400', async () => {
|
||||
mockFetch({ error: 'preferred name and email are required' }, 400)
|
||||
await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings shift signups', () => {
|
||||
it('toggleShiftSignups posts open flag', async () => {
|
||||
await saveSession('tok', { id: 1 })
|
||||
const f = mockFetch({ shift_signups_open: true })
|
||||
await api.settings.toggleShiftSignups(true)
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/settings/shift-signups')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ open: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); }
|
|||
|
||||
/* Cards */
|
||||
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
|
||||
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
|
||||
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
|
||||
|
||||
/* Stats */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
|
|
@ -103,8 +106,15 @@ input, select, textarea {
|
|||
width: 100%; font-family: var(--font);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
input[type="checkbox"] { width: auto; }
|
||||
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
|
||||
input::placeholder { color: var(--c-muted); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
|
||||
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
|
|
@ -129,8 +139,12 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
|||
font-size: 0.72rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
* + .badge { margin-left: 0.3rem; }
|
||||
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
|
||||
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
|
||||
.badge-unchecked { background: rgba(122,127,150,0.15); color: var(--c-muted); }
|
||||
.badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||
.badge-role { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
|
||||
.badge-lead { background: rgba(245,158,11,0.15); color: var(--c-warn); }
|
||||
|
||||
|
|
@ -170,8 +184,68 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
|||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Mobile header — hidden on desktop */
|
||||
.mobile-header { display: none; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.mobile-brand { font-weight: 700; font-size: 1rem; }
|
||||
.mobile-brand .accent { color: var(--c-accent); }
|
||||
.hamburger {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: none; border: none; padding: 4px; cursor: pointer;
|
||||
}
|
||||
.hamburger span {
|
||||
display: block; width: 20px; height: 2px;
|
||||
background: var(--c-text); border-radius: 1px;
|
||||
transition: transform var(--transition), opacity var(--transition);
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.nav-overlay {
|
||||
position: fixed; inset: 0; z-index: 99;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.page { padding: 1rem; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
/* Touch targets */
|
||||
.btn { min-height: 44px; padding: 0.6rem 1rem; }
|
||||
.btn-sm { min-height: 44px; padding: 0.5rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* Page header & actions */
|
||||
.page-header { flex-wrap: wrap; gap: 0.75rem; }
|
||||
.page-title { width: 100%; }
|
||||
.actions { flex-wrap: wrap; }
|
||||
|
||||
/* Search bar */
|
||||
.search-bar { flex-wrap: wrap; }
|
||||
.search-bar input { max-width: none; flex: 1 1 100%; }
|
||||
|
||||
/* Table → card layout */
|
||||
.table-wrap { overflow-x: visible; }
|
||||
table { display: block; }
|
||||
thead { display: none; }
|
||||
tbody { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
tr { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center;
|
||||
padding: 0.75rem; border: 1px solid var(--c-border); border-radius: var(--radius-lg);
|
||||
background: var(--c-surface); }
|
||||
tr:hover td { background: transparent; }
|
||||
td { display: inline; padding: 0; border: none; }
|
||||
td:empty { display: none; }
|
||||
|
||||
/* Forms — 16px prevents iOS auto-zoom on focus */
|
||||
input, select, textarea { font-size: 16px; }
|
||||
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
</script>
|
||||
|
||||
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||
{loading ? '…' : '✓ Check in'}
|
||||
{loading ? '…' : 'Mark ready'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,57 +1,54 @@
|
|||
<script>
|
||||
let { session, active, onLogout } = $props()
|
||||
import { LayoutDashboard, Heart, Hexagon, CalendarDays, Upload, Users, Settings, LogOut, Ticket } from 'lucide-svelte'
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
let { session, active, onLogout, navigate, open = false } = $props()
|
||||
|
||||
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(() => {
|
||||
if (role === 'ticketing') return [
|
||||
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||
if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
]
|
||||
if (role === 'volunteer_lead') return [
|
||||
{ href: '#/', label: 'Schedule', icon: '◷' },
|
||||
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||
if (!hasRole('admin') && hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
]
|
||||
if (role === '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 [
|
||||
{ href: '#/', label: 'Dashboard', icon: '⊞' },
|
||||
{ href: '#/attendees', label: 'Attendees', icon: '✓' },
|
||||
{ href: '#/volunteers', label: 'Volunteers', icon: '◎' },
|
||||
{ href: '#/departments', label: 'Departments', icon: '⬡' },
|
||||
{ href: '#/shifts', label: 'Shifts', icon: '◑' },
|
||||
{ href: '#/schedule', label: 'Schedule', icon: '◷' },
|
||||
{ href: '#/import', label: 'Import', icon: '↑' },
|
||||
{ href: '#/users', label: 'Users', icon: '⊕' },
|
||||
{ href: '#/settings', label: 'Settings', icon: '⚙' },
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/participants', label: 'Participants', icon: Ticket },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/import', label: 'Import', icon: Upload },
|
||||
{ href: '/users', label: 'Users', icon: Users },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
})
|
||||
|
||||
function isActive(href) {
|
||||
const p = href.replace(/^#/, '')
|
||||
if (p === '/') return active === '/' || active === ''
|
||||
return active.startsWith(p)
|
||||
if (href === '/') return active === '/' || active === ''
|
||||
return active.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar">
|
||||
<nav class="sidebar" class:open>
|
||||
<div class="sidebar-brand">Turn<span>pike</span></div>
|
||||
{#each links as link}
|
||||
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
|
||||
<span class="icon">{link.icon}</span>
|
||||
<a href={link.href} class="nav-link" class:active={isActive(link.href)}
|
||||
onclick={(e) => { e.preventDefault(); navigate(link.href) }}>
|
||||
<link.icon {...iconProps} />
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div style="flex:1"></div>
|
||||
<button class="nav-link btn-ghost" style="border:none;cursor:pointer;width:100%;text-align:left" onclick={onLogout}>
|
||||
<span class="icon">→</span> Sign out
|
||||
<LogOut {...iconProps} /> Sign out
|
||||
</button>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
<div class="sync-bar">
|
||||
<div class="sync-dot {dotClass}"></div>
|
||||
<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>
|
||||
{#if online && !syncing}
|
||||
<button class="btn btn-ghost btn-sm" onclick={manualSync}>↻</button>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,42 @@ db.version(2).stores({
|
|||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(3).stores({
|
||||
session: 'id, token, user',
|
||||
meta: 'key',
|
||||
event: 'id',
|
||||
attendees: 'id, name, ticket_type, checked_in, volunteer_token, deleted_at',
|
||||
participants: 'id, email, preferred_name, updated_at, deleted_at',
|
||||
tickets: 'id, participant_id, code, source, checked_in_at, updated_at, deleted_at',
|
||||
departments: 'id, name, deleted_at',
|
||||
volunteers: 'id, name, department_id, checked_in, attendee_id, participant_id, deleted_at',
|
||||
shifts: 'id, department_id, day, position, deleted_at',
|
||||
volunteer_shifts: '[volunteer_id+shift_id], volunteer_id, shift_id',
|
||||
outbox: '++id, table, op, synced_at',
|
||||
})
|
||||
|
||||
db.version(4).stores({
|
||||
attendees: null,
|
||||
outbox: null,
|
||||
volunteers: 'id, name, department_id, checked_in, participant_id, deleted_at',
|
||||
})
|
||||
|
||||
db.version(5).stores({
|
||||
volunteers: 'id, participant_id, department_id, deleted_at',
|
||||
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
|
||||
})
|
||||
|
||||
db.version(6).stores({}).upgrade(async tx => {
|
||||
await tx.table('session').clear()
|
||||
await tx.table('meta').clear()
|
||||
await tx.table('participants').clear()
|
||||
await tx.table('tickets').clear()
|
||||
await tx.table('departments').clear()
|
||||
await tx.table('volunteers').clear()
|
||||
await tx.table('shifts').clear()
|
||||
await tx.table('volunteer_shifts').clear()
|
||||
})
|
||||
|
||||
export async function getLastSync() {
|
||||
const m = await db.meta.get('last_sync')
|
||||
return m?.value ?? ''
|
||||
|
|
@ -44,6 +80,18 @@ export async function saveSession(token, user) {
|
|||
}
|
||||
|
||||
export async function clearSession() {
|
||||
await db.transaction('rw',
|
||||
[db.session, db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
await db.session.clear()
|
||||
await db.meta.clear()
|
||||
await db.event.clear()
|
||||
await db.participants.clear()
|
||||
await db.tickets.clear()
|
||||
await db.departments.clear()
|
||||
await db.volunteers.clear()
|
||||
await db.shifts.clear()
|
||||
await db.volunteer_shifts.clear()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
49
frontend/src/db.test.js
Normal file
49
frontend/src/db.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { db, getLastSync, setLastSync, getSession, saveSession, clearSession } from './db.js'
|
||||
|
||||
beforeEach(async () => {
|
||||
await Promise.all(db.tables.map(t => t.clear()))
|
||||
})
|
||||
|
||||
describe('db schema', () => {
|
||||
it('has expected tables', () => {
|
||||
const names = db.tables.map(t => t.name).sort()
|
||||
expect(names).toEqual([
|
||||
'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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
<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>
|
||||
148
frontend/src/pages/ConfirmEmail.svelte
Normal file
148
frontend/src/pages/ConfirmEmail.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<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,15 +2,57 @@
|
|||
import { liveQuery } from 'dexie'
|
||||
import { db } from '../db.js'
|
||||
|
||||
let { session } = $props()
|
||||
let { session, navigate } = $props()
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
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 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())
|
||||
|
||||
const total = $derived(($attendees ?? []).length)
|
||||
const checkedIn = $derived(($attendees ?? []).filter(a => a.checked_in).length)
|
||||
const remaining = $derived(total - checkedIn)
|
||||
const pct = $derived(total > 0 ? Math.round((checkedIn / total) * 100) : 0)
|
||||
// Ticket stats
|
||||
const tickets = $derived($allTickets ?? [])
|
||||
const ticketTotal = $derived(tickets.length)
|
||||
const ticketCheckedIn = $derived(tickets.filter(t => t.checked_in_at).length)
|
||||
const ticketRemaining = $derived(ticketTotal - ticketCheckedIn)
|
||||
const ticketPct = $derived(ticketTotal > 0 ? Math.round((ticketCheckedIn / ticketTotal) * 100) : 0)
|
||||
|
||||
// Volunteer stats (scoped for colead)
|
||||
const volunteers = $derived.by(() => {
|
||||
const vols = $allVolunteers ?? []
|
||||
if (isColead) return vols.filter(v => myDeptIDs.includes(v.department_id))
|
||||
return vols
|
||||
})
|
||||
const volTotal = $derived(volunteers.length)
|
||||
const volCheckedIn = $derived(volunteers.filter(v => v.ready).length)
|
||||
const volLeads = $derived(volunteers.filter(v => v.is_lead).length)
|
||||
|
||||
// Shift stats (scoped for colead)
|
||||
const shifts = $derived.by(() => {
|
||||
const all = $allShifts ?? []
|
||||
if (isColead) return all.filter(s => myDeptIDs.includes(s.department_id))
|
||||
return all
|
||||
})
|
||||
const shiftTotal = $derived(shifts.length)
|
||||
const shiftsFilled = $derived.by(() => {
|
||||
const vs = $allVS ?? []
|
||||
return shifts.filter(s => vs.some(a => a.shift_id === s.id)).length
|
||||
})
|
||||
const shiftFillPct = $derived(shiftTotal > 0 ? Math.round((shiftsFilled / shiftTotal) * 100) : 0)
|
||||
|
||||
// Department names for colead header
|
||||
const myDeptNames = $derived.by(() => {
|
||||
const depts = $allDepts ?? []
|
||||
return myDeptIDs.map(id => depts.find(d => d.id === id)?.name).filter(Boolean)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
|
@ -28,35 +70,113 @@
|
|||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isColead && myDeptNames.length > 0}
|
||||
<p style="margin-bottom:1.5rem;font-size:0.9rem">
|
||||
Your department{myDeptNames.length > 1 ? 's' : ''}:
|
||||
<strong>{myDeptNames.join(', ')}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Ticket check-in (admin) -->
|
||||
{#if isAdmin}
|
||||
<h2 class="dash-section">Ticket Check-in</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total</div>
|
||||
<div class="stat-value">{total}</div>
|
||||
<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)">{checkedIn}</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">{remaining}</div>
|
||||
<div class="stat-value">{ticketRemaining}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Progress</div>
|
||||
<div class="stat-value">{pct}%</div>
|
||||
<div class="stat-value">{ticketPct}%</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>
|
||||
{#if ticketTotal > 0}
|
||||
<div style="height:8px;background:var(--c-border);border-radius:99px;overflow:hidden;margin-bottom:2rem">
|
||||
<div style="height:100%;width:{ticketPct}%;background:var(--c-success);border-radius:99px;transition:width 0.4s ease"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Volunteer stats (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total</div>
|
||||
<div class="stat-value">{volTotal}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Checked in</div>
|
||||
<div class="stat-value" style="color:var(--c-success)">{volCheckedIn}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Leads</div>
|
||||
<div class="stat-value">{volLeads}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-muted" style="font-size:0.85rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||
<!-- Shift coverage (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total shifts</div>
|
||||
<div class="stat-value">{shiftTotal}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">With volunteers</div>
|
||||
<div class="stat-value">{shiftsFilled}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Fill rate</div>
|
||||
<div class="stat-value">{shiftFillPct}%</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick actions -->
|
||||
{#if isAdmin}
|
||||
<div class="dash-actions">
|
||||
<a href="/import" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/import') }}>Import CSV</a>
|
||||
<a href="/participants" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/participants') }}>Manage Participants</a>
|
||||
<a href="/settings" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/settings') }}>Settings</a>
|
||||
</div>
|
||||
{:else if isStaffing || isColead}
|
||||
<div class="dash-actions">
|
||||
<a href="/schedule" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/schedule') }}>View Schedule</a>
|
||||
<a href="/volunteers" class="btn btn-ghost btn-sm" onclick={(e) => { e.preventDefault(); navigate('/volunteers') }}>Manage Volunteers</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
|
||||
· {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
|
||||
</p>
|
||||
</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,9 +18,10 @@
|
|||
let editDesc = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canCreate = $derived(['admin', 'coordinator'].includes(role))
|
||||
const canDelete = $derived(role === 'admin')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canCreate = $derived(hasRole('admin', 'staffing'))
|
||||
const canDelete = $derived(hasRole('admin'))
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
{#if showAdd && canCreate}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addDept}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
||||
<div class="form-grid-3">
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="d-name">Name *</label>
|
||||
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
|
||||
|
|
@ -111,7 +112,7 @@
|
|||
</div>
|
||||
<div class="form-group" style="margin-bottom:0">
|
||||
<label for="d-color">Color</label>
|
||||
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
|
||||
<input id="d-color" type="color" bind:value={newColor} class="color-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:1rem">
|
||||
|
|
@ -127,7 +128,7 @@
|
|||
{#if ($allDepts ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No departments yet</strong>
|
||||
<p>Add departments to organize your volunteer teams.</p>
|
||||
<p>Create departments to organize shifts and volunteer teams. Coleads are assigned to specific departments.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
|
|
@ -142,8 +143,8 @@
|
|||
<tbody>
|
||||
{#each $allDepts ?? [] as d (d.id)}
|
||||
{#if editID === d.id}
|
||||
<tr>
|
||||
<td>
|
||||
<tr class="edit-row">
|
||||
<td class="td-name">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||
<input type="color" bind:value={editColor} style="width:36px;height:36px;padding:0.1rem;border-radius:4px;cursor:pointer;flex-shrink:0" />
|
||||
<input bind:value={editName} required placeholder="Name" style="margin:0" />
|
||||
|
|
@ -153,7 +154,7 @@
|
|||
<input bind:value={editDesc} placeholder="Description" style="margin:0" />
|
||||
</td>
|
||||
{#if canCreate}
|
||||
<td>
|
||||
<td class="td-actions">
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveDept(d)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
|
|
@ -165,13 +166,13 @@
|
|||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td>
|
||||
<td class="td-name">
|
||||
<span class="dept-dot" style="background:{d.color};margin-right:0.5rem"></span>
|
||||
<strong>{d.name}</strong>
|
||||
</td>
|
||||
<td class="text-muted">{d.description || '—'}</td>
|
||||
<td class="td-desc text-muted">{d.description || '—'}</td>
|
||||
{#if canCreate}
|
||||
<td>
|
||||
<td class="td-actions">
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(d)}>Edit</button>
|
||||
{#if canDelete}
|
||||
|
|
@ -188,3 +189,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
|
||||
@media (max-width: 640px) {
|
||||
.td-name { width: 100%; }
|
||||
.td-desc { width: 100%; }
|
||||
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
let { session, onLogout } = $props()
|
||||
|
||||
let search = $state('')
|
||||
let manuallySelectedId = $state(null)
|
||||
let showAll = $state(false)
|
||||
let error = $state('')
|
||||
let scannerMsg = $state('')
|
||||
let qrSupported = $state(false)
|
||||
|
|
@ -16,36 +18,89 @@
|
|||
let detector = $state(null)
|
||||
let scanInterval = $state(null)
|
||||
|
||||
const attendees = liveQuery(() =>
|
||||
db.attendees.filter(a => !a.deleted_at).toArray()
|
||||
const tickets = liveQuery(() =>
|
||||
db.tickets.filter(t => !t.deleted_at).toArray()
|
||||
)
|
||||
|
||||
const participants = liveQuery(() =>
|
||||
db.participants.filter(p => !p.deleted_at).toArray()
|
||||
)
|
||||
|
||||
const recentCheckIns = liveQuery(() =>
|
||||
db.attendees
|
||||
.filter(a => a.checked_in && !a.deleted_at)
|
||||
db.tickets
|
||||
.filter(t => !!t.checked_in_at && !t.deleted_at)
|
||||
.toArray()
|
||||
.then(arr => arr
|
||||
.filter(a => a.checked_in_at)
|
||||
.sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at))
|
||||
.slice(0, 10)
|
||||
)
|
||||
)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
// Exact code/external_id match (QR scan or typed code)
|
||||
const matchedTicket = $derived.by(() => {
|
||||
const s = search.trim()
|
||||
if (!s || s.length < 2) return null
|
||||
const sl = s.toLowerCase()
|
||||
const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl)
|
||||
if (byCode) return byCode
|
||||
return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null
|
||||
})
|
||||
|
||||
const allParticipantsSorted = $derived.by(() =>
|
||||
($participants ?? [])
|
||||
.filter(p => !p.deleted_at)
|
||||
.sort((a, b) => (a.preferred_name || a.email || '').localeCompare(b.preferred_name || b.email || ''))
|
||||
)
|
||||
|
||||
// Clear manual selection whenever search text changes
|
||||
$effect(() => {
|
||||
search
|
||||
manuallySelectedId = null
|
||||
})
|
||||
|
||||
// Name/email/ticket-name search across participants
|
||||
const filteredParticipants = $derived.by(() => {
|
||||
if (matchedTicket) return []
|
||||
const s = search.trim().toLowerCase()
|
||||
if (!s || s.length < 2) return []
|
||||
return ($attendees ?? [])
|
||||
.filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const byTicketName = new Set(
|
||||
($tickets ?? [])
|
||||
.filter(t => t.name?.toLowerCase().includes(s))
|
||||
.map(t => t.participant_id)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return ($participants ?? [])
|
||||
.filter(p =>
|
||||
p.preferred_name?.toLowerCase().includes(s) ||
|
||||
p.email?.toLowerCase().includes(s) ||
|
||||
byTicketName.has(p.id)
|
||||
)
|
||||
.sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || ''))
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
const selected = $derived.by(() => {
|
||||
if (filtered.length === 1) return filtered[0]
|
||||
const s = search.trim().toLowerCase()
|
||||
return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null
|
||||
// Manual selection takes priority; fall back to auto-select on single match
|
||||
const selectedParticipant = $derived.by(() => {
|
||||
if (manuallySelectedId) {
|
||||
return ($participants ?? []).find(p => p.id === manuallySelectedId) ?? null
|
||||
}
|
||||
if (filteredParticipants.length === 1) return filteredParticipants[0]
|
||||
return null
|
||||
})
|
||||
|
||||
function ticketsFor(participantId) {
|
||||
return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at)
|
||||
}
|
||||
|
||||
function participantFor(ticket) {
|
||||
if (!ticket?.participant_id) return null
|
||||
return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null
|
||||
}
|
||||
|
||||
function nameFor(ticket) {
|
||||
return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)'
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
qrSupported = 'BarcodeDetector' in window
|
||||
})
|
||||
|
|
@ -96,40 +151,19 @@
|
|||
} catch {}
|
||||
}
|
||||
|
||||
async function checkIn(attendee, count = 1) {
|
||||
async function checkInTicket(ticket) {
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id, { count })
|
||||
if (result.attendee) {
|
||||
await db.attendees.put(result.attendee)
|
||||
const result = await api.tickets.checkIn(ticket.id)
|
||||
if (result.ticket) {
|
||||
await db.tickets.put(result.ticket)
|
||||
search = ''
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInWithVolunteer(attendee) {
|
||||
error = ''
|
||||
try {
|
||||
const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true })
|
||||
if (result.attendee) await db.attendees.put(result.attendee)
|
||||
if (result.volunteer) await db.volunteers.put(result.volunteer)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function remaining(a) {
|
||||
return (a.party_size ?? 1) - (a.checked_in_count ?? 0)
|
||||
}
|
||||
|
||||
function progressLabel(a) {
|
||||
const ps = a.party_size ?? 1
|
||||
const ci = a.checked_in_count ?? 0
|
||||
if (ps <= 1) return null
|
||||
return `${ci}/${ps} checked in`
|
||||
}
|
||||
|
||||
function fmt(ts) {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
|
|
@ -155,6 +189,9 @@
|
|||
{scanning ? '■ Stop' : '⊡ Scan QR'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => { showAll = !showAll; search = ''; manuallySelectedId = null }}>
|
||||
{showAll ? '✕ Close' : '☰ Browse'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if scanning}
|
||||
|
|
@ -172,74 +209,122 @@
|
|||
<div class="gate-msg gate-msg-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Matched attendee card -->
|
||||
{#if selected}
|
||||
{@const rem = remaining(selected)}
|
||||
{@const prog = progressLabel(selected)}
|
||||
<!-- Exact code/ID match card -->
|
||||
{#if matchedTicket}
|
||||
{@const p = participantFor(matchedTicket)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name">{selected.name}</div>
|
||||
{#if selected.ticket_type}
|
||||
<div class="gate-match-sub">{selected.ticket_type}</div>
|
||||
<div class="gate-match-name">{nameFor(matchedTicket)}</div>
|
||||
{#if matchedTicket.ticket_type}
|
||||
<div class="gate-match-sub">{matchedTicket.ticket_type}</div>
|
||||
{/if}
|
||||
{#if selected.ticket_id}
|
||||
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
|
||||
{#if matchedTicket.external_id}
|
||||
<div class="gate-match-sub text-muted">#{matchedTicket.external_id}</div>
|
||||
{/if}
|
||||
{#if prog}
|
||||
<div class="gate-party">
|
||||
<span class="gate-party-label">{prog}</span>
|
||||
</div>
|
||||
{#if p?.email}
|
||||
<div class="gate-match-sub text-muted">{p.email}</div>
|
||||
{/if}
|
||||
|
||||
<div class="gate-match-actions">
|
||||
{#if rem > 0}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
|
||||
✓ Check in 1
|
||||
{#if !matchedTicket.checked_in_at}
|
||||
<button class="gbtn gbtn-success" onclick={() => checkInTicket(matchedTicket)}>
|
||||
✓ Check in
|
||||
</button>
|
||||
{#if rem > 1}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
|
||||
Check in all {rem}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="gate-done">All checked in</span>
|
||||
<span class="gate-done">✓ Checked in {fmt(matchedTicket.checked_in_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected.volunteer_token && !selected.checked_in}
|
||||
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
|
||||
+ Volunteer
|
||||
</button>
|
||||
<!-- Single participant match — show their tickets -->
|
||||
{:else if selectedParticipant}
|
||||
{@const pts = ticketsFor(selectedParticipant.id)}
|
||||
<div class="gate-match">
|
||||
<div class="gate-match-name-row">
|
||||
<div class="gate-match-name">{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}</div>
|
||||
{#if manuallySelectedId}
|
||||
<button class="gbtn gbtn-ghost" style="font-size:0.8rem;padding:0.3rem 0.6rem"
|
||||
onclick={() => manuallySelectedId = null}>← Back</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if search.trim().length >= 2 && filtered.length > 1}
|
||||
<!-- Multiple results list -->
|
||||
<div class="gate-results">
|
||||
{#each filtered as a}
|
||||
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
|
||||
{#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>{a.name}</strong>
|
||||
{#if a.ticket_type} · {a.ticket_type}{/if}
|
||||
<strong>{tk.name || '(unnamed)'}</strong>
|
||||
{#if tk.ticket_type}<span class="text-muted"> · {tk.ticket_type}</span>{/if}
|
||||
</span>
|
||||
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{a.checked_in ? 'In' : 'Pending'}
|
||||
{#if tk.checked_in_at}
|
||||
<span class="gate-done" style="font-size:0.8rem">✓ {fmt(tk.checked_in_at)}</span>
|
||||
{:else}
|
||||
<button class="gbtn gbtn-success" style="padding:0.3rem 0.75rem;font-size:0.8rem"
|
||||
onclick={() => checkInTicket(tk)}>✓ Check in</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Multiple participant matches -->
|
||||
{:else if search.trim().length >= 2 && filteredParticipants.length > 1}
|
||||
<div class="gate-results">
|
||||
{#each filteredParticipants as p}
|
||||
{@const pts = ticketsFor(p.id)}
|
||||
{@const ci = pts.filter(t => t.checked_in_at).length}
|
||||
<button class="gate-result-row" onclick={() => manuallySelectedId = p.id}>
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if search.trim().length >= 2 && filtered.length === 0}
|
||||
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent check-ins -->
|
||||
<div class="gate-recent">
|
||||
<div class="gate-recent-title">Recent Check-ins</div>
|
||||
{#if ($recentCheckIns ?? []).length === 0}
|
||||
<div class="gate-recent-empty">No check-ins yet today.</div>
|
||||
<div class="gate-recent-empty">No check-ins yet.</div>
|
||||
{:else}
|
||||
{#each $recentCheckIns ?? [] as a}
|
||||
{#each $recentCheckIns ?? [] as tk}
|
||||
<div class="gate-recent-row">
|
||||
<span>{a.name}</span>
|
||||
<span class="text-muted">{fmt(a.checked_in_at)}</span>
|
||||
<span>{nameFor(tk)}</span>
|
||||
<span class="text-muted">{fmt(tk.checked_in_at)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -354,7 +439,8 @@
|
|||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
|
||||
.gate-match-name-row { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.2rem; }
|
||||
.gate-match-name { font-size: 1.4rem; font-weight: 700; }
|
||||
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
|
||||
.gate-party {
|
||||
margin: 0.5rem 0;
|
||||
|
|
@ -384,6 +470,16 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.gate-ticket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--c-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-results {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
<strong style="color:var(--c-text)">Supported formats:</strong><br>
|
||||
<strong>CrowdWork / ticketing platform:</strong> columns <code>Patron Name</code>, <code>Patron Email</code>, <code>Tier Name</code>, <code>Order Number</code><br>
|
||||
<strong>Generic:</strong> columns <code>name</code>, <code>email</code>, <code>ticket_id</code>, <code>ticket_type</code>, <code>note</code><br>
|
||||
Duplicate names are skipped.
|
||||
Duplicate tickets (same source + external ID) are skipped. Participants are matched or created by email.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" disabled={!file || importing}>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
import { saveSession } from '../db.js'
|
||||
|
||||
let { onlogin } = $props()
|
||||
let { onlogin, error: externalError = '' } = $props()
|
||||
|
||||
let username = $state('')
|
||||
let email = $state('')
|
||||
let password = $state('')
|
||||
let error = $state('')
|
||||
|
||||
$effect(() => { if (externalError) error = externalError })
|
||||
let loading = $state(false)
|
||||
let ssoEnabled = $state(false)
|
||||
let ssoLoading = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api.sso.enabled()
|
||||
ssoEnabled = res.enabled
|
||||
} catch {}
|
||||
})
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
error = ''
|
||||
loading = true
|
||||
try {
|
||||
const { token, user } = await api.login(username, password)
|
||||
const { token, user } = await api.login(email, password)
|
||||
await saveSession(token, user)
|
||||
onlogin({ token, user })
|
||||
} catch (err) {
|
||||
|
|
@ -23,6 +35,18 @@
|
|||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startSSO() {
|
||||
error = ''
|
||||
ssoLoading = true
|
||||
try {
|
||||
const { redirect_url } = await api.sso.init()
|
||||
window.location.href = redirect_url
|
||||
} catch (err) {
|
||||
error = err.message || 'SSO failed'
|
||||
ssoLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
|
@ -34,8 +58,8 @@
|
|||
{/if}
|
||||
<form onsubmit={submit}>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" bind:value={username} autocomplete="username" required />
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
|
@ -45,5 +69,28 @@
|
|||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{#if ssoEnabled}
|
||||
<div class="sso-divider"><span>or</span></div>
|
||||
<button class="btn btn-ghost" style="width:100%" onclick={startSSO} disabled={ssoLoading}>
|
||||
{ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sso-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
gap: 0.75rem;
|
||||
color: var(--c-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.sso-divider::before,
|
||||
.sso-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
528
frontend/src/pages/Participants.svelte
Normal file
528
frontend/src/pages/Participants.svelte
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
<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,12 +10,24 @@
|
|||
let editShift = $state({})
|
||||
let saving = $state(false)
|
||||
|
||||
// Add shift form
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newDeptID = $state('')
|
||||
let newName = $state('')
|
||||
let newDay = $state('')
|
||||
let newStart = $state('')
|
||||
let newEnd = $state('')
|
||||
let newCapacity = $state(0)
|
||||
|
||||
// For volunteer assignment dropdown
|
||||
let assigningShiftID = $state(null)
|
||||
let assignVolID = $state(0)
|
||||
let assigning = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
|
|
@ -43,7 +55,7 @@
|
|||
// Departments visible to this user
|
||||
const visibleDepts = $derived.by(() => {
|
||||
const depts = $allDepts ?? []
|
||||
if (role === 'volunteer_lead') return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
return depts
|
||||
})
|
||||
|
||||
|
|
@ -123,11 +135,13 @@
|
|||
|
||||
try {
|
||||
const res = await api.shifts.reorder(positions)
|
||||
if (res && !res.ok) throw new Error()
|
||||
if (res && !res.ok) throw new Error('Reorder failed')
|
||||
await db.transaction('rw', db.shifts, async () => {
|
||||
for (const p of positions) {
|
||||
const s = await db.shifts.get(p.id)
|
||||
if (s) await db.shifts.put({ ...s, position: p.position })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
|
|
@ -194,6 +208,48 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function addShift(e) {
|
||||
e.preventDefault()
|
||||
if (!newDeptID) return
|
||||
adding = true
|
||||
error = ''
|
||||
try {
|
||||
const s = await api.shifts.create({
|
||||
department_id: parseInt(newDeptID),
|
||||
name: newName,
|
||||
day: newDay,
|
||||
start_time: newStart,
|
||||
end_time: newEnd,
|
||||
capacity: parseInt(newCapacity) || 0,
|
||||
})
|
||||
await db.shifts.put(s)
|
||||
showAdd = false
|
||||
newName = newDay = newStart = newEnd = ''
|
||||
newDeptID = ''
|
||||
newCapacity = 0
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
adding = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShift(s) {
|
||||
if (!confirm(`Delete shift "${s.name}"?`)) return
|
||||
try {
|
||||
await api.shifts.delete(s.id)
|
||||
await db.shifts.delete(s.id)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
function formatDay(d) {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d + 'T00:00:00')
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function fmt(t) {
|
||||
if (!t) return ''
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
|
|
@ -204,17 +260,66 @@
|
|||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Schedule Board</h1>
|
||||
<h1 class="page-title">Schedule</h1>
|
||||
{#if canManage}
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add shift</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if ($allShifts ?? []).length === 0}
|
||||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addShift}>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="s-dept">Department *</label>
|
||||
<select id="s-dept" bind:value={newDeptID} required>
|
||||
<option value="">Select department…</option>
|
||||
{#each visibleDepts as d}
|
||||
<option value={String(d.id)}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-name">Shift name *</label>
|
||||
<input id="s-name" bind:value={newName} required placeholder="e.g. Gate Morning" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-day">Day *</label>
|
||||
<input id="s-day" type="date" bind:value={newDay} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-cap">Capacity <span class="text-muted">(0 = unlimited)</span></label>
|
||||
<input id="s-cap" type="number" min="0" bind:value={newCapacity} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-start">Start time *</label>
|
||||
<input id="s-start" type="time" bind:value={newStart} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="s-end">End time *</label>
|
||||
<input id="s-end" type="time" bind:value={newEnd} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={adding}>
|
||||
{adding ? 'Adding…' : 'Add shift'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($allShifts ?? []).length === 0 && !showAdd}
|
||||
<div class="empty">
|
||||
<strong>No shifts yet</strong>
|
||||
<p>Create shifts in the Shifts page first.</p>
|
||||
<strong>No shifts scheduled yet</strong>
|
||||
<p>Create departments first, then add shifts here. Volunteers can self-select shifts via the kiosk.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each board as { dept, days }}
|
||||
|
|
@ -226,7 +331,7 @@
|
|||
</div>
|
||||
|
||||
{#each days as [day, rows]}
|
||||
<div class="board-day-label">{day}</div>
|
||||
<div class="board-day-label">{formatDay(day)}</div>
|
||||
|
||||
{#each rows as { shift, assigned, hasConflict }, i}
|
||||
<div class="board-shift {hasConflict ? 'board-shift-conflict' : ''}">
|
||||
|
|
@ -275,17 +380,20 @@
|
|||
<span class="board-cap">{assigned.length}</span>
|
||||
{/if}
|
||||
{#if hasConflict}
|
||||
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span>
|
||||
<span class="badge badge-lead">⚠ conflict</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="board-shift-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Move up"
|
||||
onclick={() => reorder(shift.id, -1, rows)}>↑</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Move down"
|
||||
onclick={() => reorder(shift.id, 1, rows)}>↓</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Assigned volunteers -->
|
||||
|
|
@ -294,21 +402,28 @@
|
|||
{#each assigned as { vs, volunteer }}
|
||||
<div class="board-vol-chip">
|
||||
{volunteer.name}
|
||||
{#if volunteer.is_lead}
|
||||
<span class="chip-lead">Co-Lead</span>
|
||||
{/if}
|
||||
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
|
||||
<span title="Scheduling conflict" style="color:var(--c-warn)">⚠</span>
|
||||
{/if}
|
||||
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
|
||||
{#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Assign volunteer -->
|
||||
{#if canManage}
|
||||
{#if assigningShiftID === shift.id}
|
||||
<div class="board-assign-row">
|
||||
<select bind:value={assignVolID} style="width:auto">
|
||||
<option value={0}>— Select volunteer —</option>
|
||||
{#each $allVolunteers ?? [] as v}
|
||||
{#each ($allVolunteers ?? [])
|
||||
.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>
|
||||
{/each}
|
||||
</select>
|
||||
|
|
@ -324,6 +439,7 @@
|
|||
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
|
@ -411,6 +527,14 @@
|
|||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chip-lead {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
background: rgba(245,158,11,0.2);
|
||||
color: var(--c-warn);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.board-vol-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
import { db } from '../db.js'
|
||||
|
||||
let loading = $state(true)
|
||||
let saving = $state(false)
|
||||
let savingEvent = $state(false)
|
||||
let testing = $state(false)
|
||||
let resetting = $state(false)
|
||||
let error = $state('')
|
||||
let success = $state('')
|
||||
|
||||
|
|
@ -16,8 +19,28 @@
|
|||
let smtpFromName = $state('')
|
||||
let baseURL = $state('')
|
||||
let testEmail = $state('')
|
||||
let noteLabel = $state('Additional note')
|
||||
let noteRequired = $state(false)
|
||||
let eventName = $state('')
|
||||
let eventVenue = $state('')
|
||||
let eventStartDate = $state('')
|
||||
let eventEndDate = $state('')
|
||||
let eventTimezone = $state('')
|
||||
const timezones = Intl.supportedValuesOf('timeZone')
|
||||
let discourseSSOUrl = $state('')
|
||||
let discourseSSOSecret = $state('')
|
||||
let shiftSignupsOpen = $state(false)
|
||||
let togglingSignups = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const ev = await api.event.get()
|
||||
eventName = ev.name ?? ''
|
||||
eventVenue = ev.venue ?? ''
|
||||
eventStartDate = ev.start_date ?? ''
|
||||
eventEndDate = ev.end_date ?? ''
|
||||
eventTimezone = ev.timezone ?? ''
|
||||
} catch {}
|
||||
try {
|
||||
const s = await api.settings.get()
|
||||
smtpHost = s.smtp_host ?? ''
|
||||
|
|
@ -27,6 +50,11 @@
|
|||
smtpFrom = s.smtp_from ?? ''
|
||||
smtpFromName = s.smtp_from_name ?? ''
|
||||
baseURL = s.base_url ?? ''
|
||||
noteLabel = s.volunteer_note_label ?? 'Additional note'
|
||||
noteRequired = s.volunteer_note_required ?? false
|
||||
discourseSSOUrl = s.discourse_sso_url ?? ''
|
||||
discourseSSOSecret = ''
|
||||
shiftSignupsOpen = s.shift_signups_open ?? false
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
|
|
@ -34,6 +62,28 @@
|
|||
}
|
||||
})
|
||||
|
||||
async function saveEvent(e) {
|
||||
e.preventDefault()
|
||||
savingEvent = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const updated = await api.event.update({
|
||||
name: eventName,
|
||||
venue: eventVenue,
|
||||
start_date: eventStartDate,
|
||||
end_date: eventEndDate,
|
||||
timezone: eventTimezone,
|
||||
})
|
||||
await db.event.put({ ...updated, id: 1 })
|
||||
success = 'Event saved.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
savingEvent = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
saving = true
|
||||
|
|
@ -44,12 +94,17 @@
|
|||
smtp_host: smtpHost,
|
||||
smtp_port: smtpPort,
|
||||
smtp_user: smtpUser,
|
||||
smtp_password: smtpPassword, // empty = keep existing
|
||||
smtp_password: smtpPassword,
|
||||
smtp_from: smtpFrom,
|
||||
smtp_from_name: smtpFromName,
|
||||
base_url: baseURL,
|
||||
volunteer_note_label: noteLabel,
|
||||
volunteer_note_required: noteRequired,
|
||||
discourse_sso_url: discourseSSOUrl,
|
||||
discourse_sso_secret: discourseSSOSecret,
|
||||
})
|
||||
smtpPassword = ''
|
||||
discourseSSOSecret = ''
|
||||
success = 'Settings saved.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
|
|
@ -58,6 +113,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function toggleSignups() {
|
||||
const opening = !shiftSignupsOpen
|
||||
if (opening && !confirm('This will email all confirmed volunteers their shift signup links. Continue?')) return
|
||||
togglingSignups = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await api.settings.toggleShiftSignups(opening)
|
||||
shiftSignupsOpen = result.shift_signups_open
|
||||
success = opening ? 'Shift signups opened. Emails are being sent.' : 'Shift signups closed.'
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
togglingSignups = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModel(label, fn) {
|
||||
if (resetting) return
|
||||
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
|
||||
resetting = true
|
||||
error = ''
|
||||
success = ''
|
||||
try {
|
||||
const result = await fn()
|
||||
success = `Deleted ${result.deleted} ${label}.`
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
resetting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
if (!testEmail) return
|
||||
testing = true
|
||||
|
|
@ -89,12 +177,50 @@
|
|||
{#if loading}
|
||||
<div class="text-muted">Loading…</div>
|
||||
{:else}
|
||||
<form onsubmit={save}>
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||
<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>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-group" style="grid-column:1">
|
||||
<form onsubmit={save}>
|
||||
<div class="card">
|
||||
<h2 class="card-title">SMTP Email</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="s-host">SMTP Host</label>
|
||||
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
|
||||
</div>
|
||||
|
|
@ -122,10 +248,27 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for volunteer token links)</span></label>
|
||||
<label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
|
||||
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
|
||||
</div>
|
||||
|
||||
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
|
||||
<p class="card-hint" style="margin-bottom:1rem">
|
||||
Enable DiscourseConnect SSO so users can log in with their Discourse account.
|
||||
Set the same secret in your Discourse admin under Connect > discourse connect secret.
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label for="sso-url">Discourse URL</label>
|
||||
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label for="sso-secret">SSO Secret</label>
|
||||
<input id="sso-secret" type="password" bind:value={discourseSSOSecret}
|
||||
placeholder="Leave blank to keep existing" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Settings'}
|
||||
|
|
@ -136,7 +279,7 @@
|
|||
|
||||
<!-- Test email -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
|
||||
<h2 class="card-title">Test Email</h2>
|
||||
<div style="display:flex;gap:0.5rem;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
<label for="s-test">Send to</label>
|
||||
|
|
@ -147,5 +290,69 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volunteer Signup -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">Volunteer Signup</h2>
|
||||
<div class="form-group">
|
||||
<label for="s-note-label">Note Field Label</label>
|
||||
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={noteRequired} />
|
||||
Note field is required
|
||||
</label>
|
||||
<p class="card-hint" style="margin-top:0.75rem">
|
||||
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shift Signups -->
|
||||
<div class="card">
|
||||
<h2 class="card-title">Shift Signups</h2>
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<span style="font-size:0.875rem">
|
||||
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
|
||||
</span>
|
||||
<button class="btn {shiftSignupsOpen ? 'btn-ghost' : 'btn-primary'}"
|
||||
onclick={toggleSignups} disabled={togglingSignups}>
|
||||
{#if togglingSignups}
|
||||
Working…
|
||||
{:else}
|
||||
{shiftSignupsOpen ? 'Close Signups' : 'Open Signups'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if !shiftSignupsOpen}
|
||||
<p class="card-hint" style="margin-top:0.75rem">
|
||||
Opening signups will email all confirmed volunteers their shift signup links.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="card">
|
||||
<h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
|
||||
<p class="card-hint" style="margin-bottom:1rem">
|
||||
Permanently delete all records of a given type. This cannot be undone.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
||||
Delete All Tickets
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
||||
Delete All Volunteers
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
||||
Delete All Shifts
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
||||
Delete All Departments
|
||||
</button>
|
||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
||||
Delete All Shift Assignments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
<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,13 +12,14 @@
|
|||
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newUsername = $state('')
|
||||
let newEmail = $state('')
|
||||
let newName = $state('')
|
||||
let newPassword = $state('')
|
||||
let newRole = $state('gate')
|
||||
let newRoles = $state([])
|
||||
let newDeptIDs = $state([])
|
||||
|
||||
let editID = $state(null)
|
||||
let editRole = $state('')
|
||||
let editRoles = $state([])
|
||||
let editDeptIDs = $state([])
|
||||
let editPassword = $state('')
|
||||
let saving = $state(false)
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
)
|
||||
|
||||
const roles = ['admin', 'coordinator', 'ticketing', 'gate', 'volunteer_lead']
|
||||
const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper']
|
||||
|
||||
const me = $derived(session?.user?.id)
|
||||
|
||||
|
|
@ -51,15 +52,16 @@
|
|||
error = ''
|
||||
try {
|
||||
const u = await api.users.create({
|
||||
username: newUsername,
|
||||
email: newEmail,
|
||||
preferred_name: newName,
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
roles: newRoles,
|
||||
department_ids: newDeptIDs,
|
||||
})
|
||||
users = [...users, u]
|
||||
showAdd = false
|
||||
newUsername = newPassword = ''
|
||||
newRole = 'gate'
|
||||
newEmail = newName = newPassword = ''
|
||||
newRoles = []
|
||||
newDeptIDs = []
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
|
|
@ -70,7 +72,7 @@
|
|||
|
||||
function startEdit(u) {
|
||||
editID = u.id
|
||||
editRole = u.role
|
||||
editRoles = [...(u.roles || [])]
|
||||
editDeptIDs = [...(u.department_ids || [])]
|
||||
editPassword = ''
|
||||
}
|
||||
|
|
@ -83,7 +85,7 @@
|
|||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const payload = { role: editRole, department_ids: editDeptIDs }
|
||||
const payload = { roles: editRoles, department_ids: editDeptIDs }
|
||||
if (editPassword) payload.password = editPassword
|
||||
const updated = await api.users.update(u.id, payload)
|
||||
users = users.map(x => x.id === u.id ? updated : x)
|
||||
|
|
@ -96,7 +98,7 @@
|
|||
}
|
||||
|
||||
async function deleteUser(u) {
|
||||
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
||||
if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return
|
||||
try {
|
||||
await api.users.delete(u.id)
|
||||
users = users.filter(x => x.id !== u.id)
|
||||
|
|
@ -105,7 +107,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleDept(id, list) {
|
||||
function toggleItem(id, list) {
|
||||
const idx = list.indexOf(id)
|
||||
if (idx === -1) return [...list, id]
|
||||
return list.filter(x => x !== id)
|
||||
|
|
@ -117,7 +119,7 @@
|
|||
}
|
||||
|
||||
function roleLabel(r) {
|
||||
return { admin: 'Admin', coordinator: 'Coordinator', ticketing: 'Ticketing', gate: 'Gate', volunteer_lead: 'Vol. Lead' }[r] || r
|
||||
return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -129,6 +131,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
|
||||
<strong style="color:var(--c-text)">Roles:</strong>
|
||||
admin — full access ·
|
||||
staffing — volunteers, shifts, departments ·
|
||||
colead — manage assigned departments only ·
|
||||
gatekeeper — check-in only
|
||||
</p>
|
||||
|
||||
{#if loadError}
|
||||
<div class="alert alert-error">{loadError}</div>
|
||||
{/if}
|
||||
|
|
@ -139,22 +149,31 @@
|
|||
{#if showAdd}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addUser}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div class="form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="u-username">Username *</label>
|
||||
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
||||
<label for="u-email">Email *</label>
|
||||
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-name">Preferred Name</label>
|
||||
<input id="u-name" bind:value={newName} placeholder="Preferred name" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-password">Password *</label>
|
||||
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-role">Role *</label>
|
||||
<select id="u-role" bind:value={newRole}>
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||
{#each availableRoles as r}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
checked={newRoles.includes(r)}
|
||||
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -162,10 +181,10 @@
|
|||
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
|
||||
{#each $allDepts ?? [] as d}
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
checked={newDeptIDs.includes(d.id)}
|
||||
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
|
||||
onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
|
||||
<span class="dept-dot" style="background:{d.color}"></span>
|
||||
{d.name}
|
||||
</label>
|
||||
|
|
@ -187,15 +206,16 @@
|
|||
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="empty">
|
||||
<strong>No users yet</strong>
|
||||
<strong>No additional users</strong>
|
||||
<p>The admin account was created at setup. Add users above to delegate access.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Preferred Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Departments</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -203,23 +223,28 @@
|
|||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
{#if editID === u.id}
|
||||
<tr>
|
||||
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<tr class="edit-row">
|
||||
<td class="td-name"><strong>{u.preferred_name || u.email}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<td>
|
||||
<select bind:value={editRole} style="width:auto;margin:0">
|
||||
{#each roles as r}
|
||||
<option value={r}>{roleLabel(r)}</option>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each availableRoles as r}
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox"
|
||||
checked={editRoles.includes(r)}
|
||||
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each $allDepts ?? [] as d}
|
||||
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||
<input type="checkbox" style="width:auto"
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox"
|
||||
checked={editDeptIDs.includes(d.id)}
|
||||
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
||||
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
||||
{d.name}
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -229,7 +254,7 @@
|
|||
placeholder="New password (leave blank to keep)"
|
||||
style="margin-top:0.5rem" autocomplete="new-password" />
|
||||
</td>
|
||||
<td>
|
||||
<td class="td-actions">
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
|
|
@ -240,19 +265,20 @@
|
|||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{u.username}</strong>
|
||||
<td class="td-name">
|
||||
<strong>{u.preferred_name || u.email}</strong>
|
||||
{#if u.id === me}
|
||||
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
||||
<span class="badge badge-role">you</span>
|
||||
{/if}
|
||||
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
|
||||
</td>
|
||||
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
||||
<td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
|
||||
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
||||
<td>
|
||||
<td class="td-actions">
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
||||
{#if u.id !== me}
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -264,3 +290,11 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.td-name { width: 100%; }
|
||||
.td-actions { width: 100%; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
import { onMount } from 'svelte'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// Token comes from the URL hash: /#/v/TOKEN
|
||||
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
const token = $derived(window.location.pathname.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
|
||||
|
||||
let state = $state(null) // { volunteer, shifts, available }
|
||||
let loading = $state(true)
|
||||
|
|
@ -150,7 +149,7 @@
|
|||
<div class="kiosk-vol-name">{state.volunteer.name}</div>
|
||||
<div class="kiosk-vol-meta">
|
||||
{state.volunteer.email || ''}
|
||||
{state.volunteer.is_lead ? ' · Department Lead' : ''}
|
||||
{state.volunteer.is_lead ? ' · Co-Lead' : ''}
|
||||
</div>
|
||||
<div class="kiosk-token">Token: <code>{token}</code></div>
|
||||
</div>
|
||||
241
frontend/src/pages/VolunteerSignup.svelte
Normal file
241
frontend/src/pages/VolunteerSignup.svelte
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<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,23 +8,43 @@
|
|||
|
||||
let search = $state('')
|
||||
let filterDept = $state('')
|
||||
let filterChecked = $state('')
|
||||
let filterStatus = $state('')
|
||||
let error = $state('')
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newName = $state('')
|
||||
let newTicketName = $state('')
|
||||
let newEmail = $state('')
|
||||
let newPhone = $state('')
|
||||
let newDeptID = $state('')
|
||||
let newIsLead = $state(false)
|
||||
let newNote = $state('')
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'coordinator', 'volunteer_lead'].includes(role))
|
||||
let editID = $state(null)
|
||||
let editDeptID = $state('')
|
||||
let editIsLead = $state(false)
|
||||
let editNote = $state('')
|
||||
let saving = $state(false)
|
||||
let confirmingID = $state(null)
|
||||
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const canConfirm = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
let deptInitialized = $state(false)
|
||||
$effect(() => {
|
||||
if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) {
|
||||
filterDept = String(myDeptIDs[0])
|
||||
deptInitialized = true
|
||||
}
|
||||
})
|
||||
|
||||
const allVolunteers = liveQuery(() =>
|
||||
db.volunteers.filter(v => !v.deleted_at).toArray()
|
||||
)
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allTickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray())
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
|
@ -36,8 +56,10 @@
|
|||
return list
|
||||
.filter(v => {
|
||||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||
if (filterChecked === 'true' && !v.checked_in) return false
|
||||
if (filterChecked === 'false' && v.checked_in) return false
|
||||
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
||||
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
|
||||
if (filterStatus === 'ready' && !v.ready) return false
|
||||
if (s && !v.name.toLowerCase().includes(s) &&
|
||||
!(v.email || '').toLowerCase().includes(s)) return false
|
||||
return true
|
||||
|
|
@ -45,15 +67,28 @@
|
|||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
async function checkIn(v) {
|
||||
async function markReady(v) {
|
||||
try {
|
||||
const updated = await api.volunteers.checkIn(v.id)
|
||||
const updated = await api.volunteers.markReady(v.id)
|
||||
await db.volunteers.put(updated)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmVolunteer(v) {
|
||||
if (confirmingID) return
|
||||
confirmingID = v.id
|
||||
try {
|
||||
const updated = await api.volunteers.confirm(v.id)
|
||||
await db.volunteers.put(updated)
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
confirmingID = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addVolunteer(e) {
|
||||
e.preventDefault()
|
||||
adding = true
|
||||
|
|
@ -61,8 +96,8 @@
|
|||
try {
|
||||
const data = {
|
||||
name: newName,
|
||||
ticket_name: newTicketName,
|
||||
email: newEmail,
|
||||
phone: newPhone,
|
||||
is_lead: newIsLead,
|
||||
note: newNote,
|
||||
}
|
||||
|
|
@ -70,7 +105,7 @@
|
|||
const v = await api.volunteers.create(data)
|
||||
await db.volunteers.put(v)
|
||||
showAdd = false
|
||||
newName = newEmail = newPhone = newNote = ''
|
||||
newName = newEmail = newTicketName = newNote = ''
|
||||
newDeptID = ''
|
||||
newIsLead = false
|
||||
} catch (err) {
|
||||
|
|
@ -90,9 +125,48 @@
|
|||
}
|
||||
}
|
||||
|
||||
function startEdit(v) {
|
||||
editID = v.id
|
||||
editDeptID = v.department_id ? String(v.department_id) : ''
|
||||
editIsLead = v.is_lead
|
||||
editNote = v.note ?? ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editID = null
|
||||
}
|
||||
|
||||
async function saveVolunteer(v) {
|
||||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const updated = await api.volunteers.update(v.id, {
|
||||
...v,
|
||||
department_id: editDeptID ? parseInt(editDeptID) : null,
|
||||
is_lead: editIsLead,
|
||||
note: editNote,
|
||||
})
|
||||
await db.volunteers.put(updated)
|
||||
editID = null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function deptFor(id) {
|
||||
return ($allDepts ?? []).find(d => d.id === id)
|
||||
}
|
||||
|
||||
function participantHasTickets(participantId) {
|
||||
if (!participantId) return false
|
||||
return ($allTickets ?? []).some(t => t.participant_id === participantId)
|
||||
}
|
||||
|
||||
function participantFor(id) {
|
||||
return ($allParticipants ?? []).find(p => p.id === id) ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
|
@ -112,18 +186,18 @@
|
|||
{#if showAdd && canManage}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<form onsubmit={addVolunteer}>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="v-name">Name *</label>
|
||||
<input id="v-name" bind:value={newName} required placeholder="Full name" />
|
||||
<label for="v-name">Preferred Name *</label>
|
||||
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-email">Email</label>
|
||||
<input id="v-email" type="email" bind:value={newEmail} placeholder="email@example.com" />
|
||||
<label for="v-ticket-name">Name on Ticket</label>
|
||||
<input id="v-ticket-name" bind:value={newTicketName} placeholder="Legal/ticketed name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-phone">Phone</label>
|
||||
<input id="v-phone" bind:value={newPhone} placeholder="Optional" />
|
||||
<label for="v-email">Email *</label>
|
||||
<input id="v-email" type="email" bind:value={newEmail} required placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="v-dept">Department</label>
|
||||
|
|
@ -140,8 +214,8 @@
|
|||
<input id="v-note" bind:value={newNote} placeholder="Optional note" />
|
||||
</div>
|
||||
<div style="margin-bottom:1rem">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={newIsLead} />
|
||||
Department lead
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -165,10 +239,12 @@
|
|||
{/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 bind:value={filterStatus} style="width:auto">
|
||||
<option value="">All statuses</option>
|
||||
<option value="unconfirmed">Unregistered</option>
|
||||
<option value="registered">Registered</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ready">Ready</option>
|
||||
</select>
|
||||
<span class="text-muted" style="font-size:0.85rem;white-space:nowrap">
|
||||
{filtered.length} shown
|
||||
|
|
@ -178,14 +254,14 @@
|
|||
{#if ($allVolunteers ?? []).length === 0}
|
||||
<div class="empty">
|
||||
<strong>No volunteers yet</strong>
|
||||
<p>Add volunteers manually.</p>
|
||||
<p>Add volunteers manually above, or enable public signup in Settings.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Preferred Name</th>
|
||||
<th>Department</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
|
|
@ -195,47 +271,112 @@
|
|||
<tbody>
|
||||
{#each filtered as v (v.id)}
|
||||
{@const dept = deptFor(v.department_id)}
|
||||
{#if editID === v.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name" style="width:100%">
|
||||
<strong>{v.name}</strong>
|
||||
{#if v.email}<div class="text-muted" style="font-size:0.78rem">{v.email}</div>{/if}
|
||||
</td>
|
||||
<td class="td-edit-dept">
|
||||
<select bind:value={editDeptID} style="margin:0">
|
||||
<option value="">No department</option>
|
||||
{#each $allDepts ?? [] as d}
|
||||
<option value={String(d.id)}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="td-edit-checks">
|
||||
<label class="checkbox-label-sm" style="white-space:nowrap">
|
||||
<input type="checkbox" bind:checked={editIsLead} /> Co-Lead
|
||||
</label>
|
||||
</td>
|
||||
<td class="td-edit-note">
|
||||
<input bind:value={editNote} placeholder="Note" style="margin:0" />
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick={() => saveVolunteer(v)} disabled={saving}>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{@const participant = participantFor(v.participant_id)}
|
||||
<tr>
|
||||
<td>
|
||||
<td class="td-name">
|
||||
<strong>{v.name}</strong>
|
||||
{#if v.is_lead}
|
||||
<span class="badge badge-lead" style="margin-left:0.4rem">Lead</span>
|
||||
<span class="badge badge-lead">Co-Lead</span>
|
||||
{/if}
|
||||
{#if !v.participant_id}
|
||||
<span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
|
||||
{:else if !participantHasTickets(v.participant_id)}
|
||||
<span class="badge badge-partial" title="No ticket on file">No ticket</span>
|
||||
{/if}
|
||||
{#if participant?.ticket_name && participant.ticket_name !== v.name}
|
||||
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
|
||||
{/if}
|
||||
{#if v.email}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.email}</div>
|
||||
{/if}
|
||||
{#if v.note}
|
||||
<div class="text-muted" style="font-size:0.78rem">{v.note}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
<td class="td-dept text-muted">
|
||||
{#if dept}
|
||||
<span class="dept-dot" style="background:{dept.color};margin-right:0.4rem"></span>{dept.name}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {v.checked_in ? 'badge-checked' : 'badge-unchecked'}">
|
||||
{v.checked_in ? 'Checked in' : 'Pending'}
|
||||
</span>
|
||||
{#if v.checked_in_at}
|
||||
<td class="td-status">
|
||||
{#if v.ready}
|
||||
<span class="badge badge-checked">Ready</span>
|
||||
{:else if v.confirmed}
|
||||
<span class="badge badge-confirmed">Confirmed</span>
|
||||
{:else if v.email_confirmed}
|
||||
<span class="badge badge-registered">Registered</span>
|
||||
{:else}
|
||||
<span class="badge badge-unchecked">Unregistered</span>
|
||||
{/if}
|
||||
{#if v.ready_at}
|
||||
<div class="text-muted" style="font-size:0.75rem">
|
||||
{new Date(v.checked_in_at).toLocaleTimeString()}
|
||||
{new Date(v.ready_at).toLocaleTimeString()}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if !v.checked_in}
|
||||
<CheckInButton onclick={() => checkIn(v)} />
|
||||
<td class="td-ready">
|
||||
{#if v.confirmed && !v.ready}
|
||||
<CheckInButton onclick={() => markReady(v)} />
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
<td>
|
||||
<td class="td-actions">
|
||||
{#if canConfirm && v.email_confirmed && !v.confirmed}
|
||||
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.td-name { flex: 1; min-width: 0; order: 1; }
|
||||
.td-ready { flex-shrink: 0; align-self: flex-start; order: 2; }
|
||||
.td-dept { width: 100%; order: 3; }
|
||||
.td-status { width: 100%; order: 4; }
|
||||
.td-actions { width: 100%; order: 5; display: flex; justify-content: flex-end; }
|
||||
.edit-row td { width: 100%; }
|
||||
.td-edit-dept, .td-edit-checks, .td-edit-note { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,24 +4,54 @@ import { api } from './api.js'
|
|||
let syncing = false
|
||||
let sseSource = null
|
||||
|
||||
async function checkBuildChanged() {
|
||||
try {
|
||||
const res = await fetch('/api/version')
|
||||
const { build } = await res.json()
|
||||
if (!build) return
|
||||
const stored = await db.meta.get('build')
|
||||
if (!stored || stored.value !== build) {
|
||||
await db.transaction('rw',
|
||||
[db.meta, db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
await db.meta.clear()
|
||||
await db.event.clear()
|
||||
await db.participants.clear()
|
||||
await db.tickets.clear()
|
||||
await db.departments.clear()
|
||||
await db.volunteers.clear()
|
||||
await db.shifts.clear()
|
||||
await db.volunteer_shifts.clear()
|
||||
await db.meta.put({ key: 'build', value: build })
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function syncPull() {
|
||||
if (syncing) return
|
||||
syncing = true
|
||||
try {
|
||||
await checkBuildChanged()
|
||||
const since = await getLastSync()
|
||||
const data = await api.sync.pull(since)
|
||||
|
||||
await db.transaction('rw',
|
||||
[db.event, db.attendees, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
[db.event, db.participants, db.tickets, db.departments, db.volunteers, db.shifts, db.volunteer_shifts],
|
||||
async () => {
|
||||
if (data.event) {
|
||||
await db.event.put(data.event)
|
||||
}
|
||||
if (data.attendees?.length) {
|
||||
await db.attendees.bulkPut(data.attendees)
|
||||
// Purge hard-deleted records from Dexie
|
||||
const deleted = data.attendees.filter(a => a.deleted_at).map(a => a.id)
|
||||
if (deleted.length) await db.attendees.bulkDelete(deleted)
|
||||
if (data.participants?.length) {
|
||||
await db.participants.bulkPut(data.participants)
|
||||
const deleted = data.participants.filter(p => p.deleted_at).map(p => p.id)
|
||||
if (deleted.length) await db.participants.bulkDelete(deleted)
|
||||
}
|
||||
if (data.tickets?.length) {
|
||||
await db.tickets.bulkPut(data.tickets)
|
||||
const deleted = data.tickets.filter(t => t.deleted_at).map(t => t.id)
|
||||
if (deleted.length) await db.tickets.bulkDelete(deleted)
|
||||
}
|
||||
if (data.departments?.length) {
|
||||
await db.departments.bulkPut(data.departments)
|
||||
|
|
@ -40,11 +70,14 @@ export async function syncPull() {
|
|||
}
|
||||
if (data.volunteer_shifts?.length) {
|
||||
await db.volunteer_shifts.bulkPut(data.volunteer_shifts)
|
||||
const deleted = data.volunteer_shifts.filter(vs => vs.deleted_at)
|
||||
.map(vs => [vs.volunteer_id, vs.shift_id])
|
||||
if (deleted.length) await db.volunteer_shifts.bulkDelete(deleted)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await setLastSync(data.server_time)
|
||||
if (data.server_time) await setLastSync(data.server_time)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Sync pull failed:', err.message)
|
||||
|
|
@ -65,29 +98,32 @@ export function startSSE(onEvent) {
|
|||
|
||||
sseSource = new EventSource(`/api/sync/stream?token=${encodeURIComponent(session.token)}`)
|
||||
|
||||
sseSource.onmessage = (e) => {
|
||||
sseSource.onmessage = async (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data)
|
||||
if (payload.event === 'checkin') {
|
||||
// Apply check-in to local Dexie immediately
|
||||
if (payload.data?.type === 'attendee' && payload.data?.attendee) {
|
||||
db.attendees.put(payload.data.attendee)
|
||||
if (payload.data?.type === 'ticket' && payload.data?.ticket) {
|
||||
await db.tickets.put(payload.data.ticket)
|
||||
}
|
||||
if (payload.data?.type === 'volunteer' && payload.data?.volunteer) {
|
||||
db.volunteers.put(payload.data.volunteer)
|
||||
await db.volunteers.put(payload.data.volunteer)
|
||||
}
|
||||
onEvent?.(payload)
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn('SSE message error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
sseSource.onerror = () => {
|
||||
sseSource?.close()
|
||||
sseSource = null
|
||||
// Reconnect after 5s
|
||||
setTimeout(connect, 5000)
|
||||
setTimeout(() => {
|
||||
connect()
|
||||
syncPull()
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
connect()
|
||||
|
|
@ -98,18 +134,23 @@ export function stopSSE() {
|
|||
sseSource = null
|
||||
}
|
||||
|
||||
// Poll for sync when online, with exponential backoff on failure
|
||||
let syncInterval = null
|
||||
let onlineHandler = null
|
||||
|
||||
export function startSyncLoop(intervalMs = 30000) {
|
||||
if (syncInterval) return
|
||||
syncInterval = setInterval(() => {
|
||||
if (navigator.onLine) syncPull()
|
||||
}, intervalMs)
|
||||
window.addEventListener('online', () => syncPull())
|
||||
onlineHandler = () => syncPull()
|
||||
window.addEventListener('online', onlineHandler)
|
||||
}
|
||||
|
||||
export function stopSyncLoop() {
|
||||
clearInterval(syncInterval)
|
||||
syncInterval = null
|
||||
if (onlineHandler) {
|
||||
window.removeEventListener('online', onlineHandler)
|
||||
onlineHandler = null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
frontend/src/sync.test.js
Normal file
91
frontend/src/sync.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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
frontend/src/test-setup.js
Normal file
1
frontend/src/test-setup.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import 'fake-indexeddb/auto'
|
||||
|
|
@ -3,6 +3,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
define: {
|
||||
__BUILD_ID__: JSON.stringify(process.env.BUILD_ID || 'dev'),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8180',
|
||||
|
|
@ -12,4 +15,8 @@ export default defineConfig({
|
|||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.js'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module turnpike
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) handleListAttendees(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
attendees, err := app.listAttendees(q.Get("search"), q.Get("ticket_type"), q.Get("checked_in"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
types, _ := app.attendeeTicketTypes()
|
||||
total, checkedIn, _ := app.attendeeCounts()
|
||||
writeJSON(w, map[string]any{
|
||||
"attendees": attendees,
|
||||
"ticket_types": types,
|
||||
"total": total,
|
||||
"checked_in": checkedIn,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleCreateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
created, err := app.createAttendee(a)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
||||
func (app *App) handleGetAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, a)
|
||||
}
|
||||
|
||||
func (app *App) handleUpdateAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var a Attendee
|
||||
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if a.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.ID = id
|
||||
if err := app.updateAttendee(a); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
updated, _ := app.getAttendee(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
||||
func (app *App) handleDeleteAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteAttendee(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCheckInAttendee handles POST /api/attendees/:id/checkin.
|
||||
// Optional body: {"count": N, "also_volunteer": true}
|
||||
// Returns {"attendee": ..., "volunteer": ...} — volunteer is included if also_volunteer=true
|
||||
// and the attendee has a linked volunteer record.
|
||||
func (app *App) handleCheckInAttendee(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
AlsoVolunteer bool `json:"also_volunteer"`
|
||||
}
|
||||
body.Count = 1
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.Count < 1 {
|
||||
body.Count = 1
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
a, err := app.checkInAttendee(id, claims.UserID, body.Count)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]any{"attendee": a}
|
||||
|
||||
if body.AlsoVolunteer {
|
||||
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()
|
||||
}
|
||||
109
handle_attendees_test.go
Normal file
109
handle_attendees_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
|
@ -15,7 +15,7 @@ func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
user, hash, err := app.getUserByUsername(body.Username)
|
||||
user, hash, err := app.getLoginParticipant(body.Email)
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -40,9 +40,9 @@ func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (app *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFromContext(r)
|
||||
user, err := app.getUserByID(claims.UserID)
|
||||
user, err := app.getUser(claims.ParticipantID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
writeError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, user)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
type ImportResult struct {
|
||||
Inserted int `json:"inserted"`
|
||||
Grouped int `json:"grouped"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
|
@ -57,12 +56,14 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
}
|
||||
|
||||
var (
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx, noteIdx int
|
||||
hasEmail, hasTicketID, hasTicketType, hasNote bool
|
||||
nameIdx, emailIdx, ticketIDIdx, ticketTypeIdx int
|
||||
hasEmail, hasTicketID, hasTicketType bool
|
||||
isCrowdWork bool
|
||||
)
|
||||
|
||||
if idx, ok := colIndex["patron name"]; ok {
|
||||
// CrowdWork / ticketing platform format
|
||||
isCrowdWork = true
|
||||
nameIdx = idx
|
||||
if idx, ok := colIndex["patron email"]; ok {
|
||||
emailIdx, hasEmail = idx, true
|
||||
|
|
@ -85,9 +86,6 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
if idx, ok := colIndex["ticket_type"]; ok {
|
||||
ticketTypeIdx, hasTicketType = idx, true
|
||||
}
|
||||
if idx, ok := colIndex["note"]; ok {
|
||||
noteIdx, hasNote = idx, true
|
||||
}
|
||||
} else {
|
||||
return ImportResult{}, fmt.Errorf("CSV must have a 'name' or 'patron name' column")
|
||||
}
|
||||
|
|
@ -111,33 +109,49 @@ func (app *App) importCSV(r io.Reader) (ImportResult, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
a := Attendee{Name: name}
|
||||
email := ""
|
||||
if hasEmail {
|
||||
a.Email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
email = strings.TrimSpace(csvGet(record, emailIdx))
|
||||
}
|
||||
externalID := ""
|
||||
if hasTicketID {
|
||||
a.TicketID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
externalID = strings.TrimSpace(csvGet(record, ticketIDIdx))
|
||||
}
|
||||
ticketType := ""
|
||||
if hasTicketType {
|
||||
a.TicketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
}
|
||||
if hasNote {
|
||||
a.Note = strings.TrimSpace(csvGet(record, noteIdx))
|
||||
ticketType = strings.TrimSpace(csvGet(record, ticketTypeIdx))
|
||||
}
|
||||
|
||||
_, err = app.createAttendee(a)
|
||||
source := "manual"
|
||||
orderID := ""
|
||||
if isCrowdWork {
|
||||
source = "crowdwork"
|
||||
orderID = externalID
|
||||
}
|
||||
|
||||
// Find or create participant when email is present.
|
||||
var participantID *int
|
||||
if email != "" {
|
||||
p, _, err := app.upsertParticipant(email, name)
|
||||
if err != nil {
|
||||
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++
|
||||
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 strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
result.Skipped++
|
||||
} else {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("line %d (%s): %v", lineNum, name, err))
|
||||
|
|
|
|||
143
handle_import_test.go
Normal file
143
handle_import_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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,27 +1,26 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (app *App) volunteerFromKioskToken(token string) (*Volunteer, error) {
|
||||
return app.getVolunteerByKioskCode(token)
|
||||
}
|
||||
|
||||
// handleKioskGet returns the volunteer's profile, current shift assignments, and
|
||||
// available open shifts in their department. Authenticated by volunteer token only —
|
||||
// available open shifts in their department. Authenticated by kiosk code only —
|
||||
// no JWT required.
|
||||
func (app *App) handleKioskGet(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer record linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
assigned, _ := app.listShiftsForVolunteer(v.ID)
|
||||
if assigned == nil {
|
||||
assigned = []Shift{}
|
||||
|
|
@ -52,16 +51,11 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
force := r.URL.Query().Get("force") == "true"
|
||||
|
||||
|
|
@ -87,15 +81,12 @@ func (app *App) handleKioskClaim(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "shift not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if shift.Capacity > 0 {
|
||||
count, _ := app.shiftAssignedCount(shiftID)
|
||||
if count >= shift.Capacity {
|
||||
|
||||
if err := app.assignShiftWithCapacity(v.ID, shiftID, shift.Capacity); err != nil {
|
||||
if errors.Is(err, errShiftFull) {
|
||||
writeError(w, "shift is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.assignShift(v.ID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -112,16 +103,11 @@ func (app *App) handleKioskUnclaim(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
a, err := app.getAttendeeByToken(token)
|
||||
if err != nil || a == nil {
|
||||
v, err := app.volunteerFromKioskToken(token)
|
||||
if err != nil || v == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, _ := app.getVolunteerByAttendeeID(a.ID)
|
||||
if v == nil {
|
||||
writeError(w, "no volunteer linked to this token", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.unassignShift(v.ID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
|||
166
handle_kiosk_test.go
Normal file
166
handle_kiosk_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
179
handle_participants.go
Normal file
179
handle_participants.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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,6 +19,22 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|||
pass = "***"
|
||||
}
|
||||
|
||||
var noteLabel, noteRequired, signupsOpen string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_label'`).Scan(¬eLabel)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'volunteer_note_required'`).Scan(¬eRequired)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'shift_signups_open'`).Scan(&signupsOpen)
|
||||
if noteLabel == "" {
|
||||
noteLabel = "Additional note"
|
||||
}
|
||||
|
||||
var ssoURL, ssoSecret string
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
|
||||
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
|
||||
maskedSSOSecret := ""
|
||||
if ssoSecret != "" {
|
||||
maskedSSOSecret = "***"
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"smtp_host": cfg.Host,
|
||||
"smtp_port": cfg.Port,
|
||||
|
|
@ -27,6 +43,11 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|||
"smtp_from": cfg.From,
|
||||
"smtp_from_name": cfg.FromName,
|
||||
"base_url": baseURL,
|
||||
"volunteer_note_label": noteLabel,
|
||||
"volunteer_note_required": noteRequired == "true",
|
||||
"shift_signups_open": signupsOpen == "true",
|
||||
"discourse_sso_url": ssoURL,
|
||||
"discourse_sso_secret": maskedSSOSecret,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +58,8 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url"}
|
||||
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 {
|
||||
v, ok := body[k]
|
||||
if !ok {
|
||||
|
|
@ -46,12 +68,18 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
var val string
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if k == "smtp_password" && vv == "" {
|
||||
continue // don't erase the stored password with an empty value
|
||||
if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
|
||||
continue
|
||||
}
|
||||
val = vv
|
||||
case float64:
|
||||
val = strconv.Itoa(int(vv))
|
||||
case bool:
|
||||
if vv {
|
||||
val = "true"
|
||||
} else {
|
||||
val = "false"
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
|
@ -61,6 +89,66 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
app.handleGetSettings(w, r)
|
||||
}
|
||||
|
||||
func (app *App) handleResetTickets(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE tickets SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetVolunteers(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE volunteers SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetShifts(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
// Also soft-delete orphaned volunteer_shifts
|
||||
app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND shift_id IN (SELECT id FROM shifts WHERE deleted_at IS NOT NULL)`, ts, ts)
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
// Soft-delete shifts in these departments first (so sync picks them up)
|
||||
app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND shift_id IN (SELECT id FROM shifts WHERE department_id IN (SELECT id FROM departments WHERE deleted_at IS NULL))`, ts, ts)
|
||||
app.db.Exec(`UPDATE shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL AND department_id IN (SELECT id FROM departments WHERE deleted_at IS NULL)`, ts, ts)
|
||||
result, err := app.db.Exec(`UPDATE departments SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleResetVolunteerShifts(w http.ResponseWriter, r *http.Request) {
|
||||
ts := now()
|
||||
result, err := app.db.Exec(`UPDATE volunteer_shifts SET deleted_at=?, updated_at=? WHERE deleted_at IS NULL`, ts, ts)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
writeJSON(w, map[string]any{"deleted": n})
|
||||
}
|
||||
|
||||
func (app *App) handleTestEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
|
|
|
|||
145
handle_settings_test.go
Normal file
145
handle_settings_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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,20 +8,19 @@ import (
|
|||
|
||||
func (app *App) handleListShifts(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var deptID *int
|
||||
var deptIDs []int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||
deptIDs = claims.DeptIDs
|
||||
}
|
||||
|
||||
shifts, err := app.listShifts(deptID, q.Get("day"), q.Get("since"))
|
||||
shifts, err := app.listShifts(deptIDs, q.Get("day"), q.Get("since"))
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -40,7 +39,7 @@ func (app *App) handleCreateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
if isCoLeadOnly(claims) && !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -65,7 +64,7 @@ func (app *App) handleUpdateShift(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if isCoLeadOnly(claims) {
|
||||
existing, _ := app.getShift(id)
|
||||
if existing == nil || !inSlice(existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
|
|
@ -87,6 +86,14 @@ func (app *App) handleDeleteShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(id)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.deleteShift(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -111,6 +118,14 @@ func (app *App) handleAssignShiftVolunteer(w http.ResponseWriter, r *http.Reques
|
|||
writeError(w, "volunteer_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(shiftID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !body.Force {
|
||||
conflicts, err := app.checkShiftConflict(body.VolunteerID, shiftID)
|
||||
|
|
@ -149,6 +164,14 @@ func (app *App) handleUnassignShiftVolunteer(w http.ResponseWriter, r *http.Requ
|
|||
writeError(w, "invalid volunteer id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
s, _ := app.getShift(shiftID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -167,6 +190,16 @@ func (app *App) handleReorderShifts(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "array of {id, position} required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
for _, p := range raw {
|
||||
s, _ := app.getShift(p.ID)
|
||||
if s == nil || !inSlice(s.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
positions := make([]struct{ ID, Position int }, len(raw))
|
||||
for i, p := range raw {
|
||||
positions[i] = struct{ ID, Position int }{p.ID, p.Position}
|
||||
|
|
|
|||
213
handle_shifts_test.go
Normal file
213
handle_shifts_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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
Normal file
212
handle_signup.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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)
|
||||
}
|
||||
390
handle_signup_test.go
Normal file
390
handle_signup_test.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
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
Normal file
190
handle_sso.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
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,14 +12,18 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
since := r.URL.Query().Get("since")
|
||||
|
||||
event, _ := app.getEvent()
|
||||
attendees, _ := app.attendeesSince(since)
|
||||
participants, _ := app.listParticipants("", since)
|
||||
tickets, _ := app.listTickets(nil, since)
|
||||
departments, _ := app.listDepartments(since)
|
||||
volunteers, _ := app.listVolunteers("", nil, since)
|
||||
shifts, _ := app.listShifts(nil, "", since)
|
||||
volunteerShifts, _ := app.listVolunteerShifts(since)
|
||||
|
||||
if attendees == nil {
|
||||
attendees = []Attendee{}
|
||||
if participants == nil {
|
||||
participants = []Participant{}
|
||||
}
|
||||
if tickets == nil {
|
||||
tickets = []Ticket{}
|
||||
}
|
||||
if departments == nil {
|
||||
departments = []Department{}
|
||||
|
|
@ -37,7 +41,8 @@ func (app *App) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, map[string]any{
|
||||
"server_time": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
"event": event,
|
||||
"attendees": attendees,
|
||||
"participants": participants,
|
||||
"tickets": tickets,
|
||||
"departments": departments,
|
||||
"volunteers": volunteers,
|
||||
"shifts": shifts,
|
||||
|
|
|
|||
113
handle_sync_test.go
Normal file
113
handle_sync_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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"
|
||||
)
|
||||
|
||||
// handleGenerateTokens creates volunteer_token values for all attendees that don't have one.
|
||||
// handleGenerateTokens creates codes for all tickets that don't have one.
|
||||
func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := app.generateTokensForAll()
|
||||
count, err := app.generateCodesForAll()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -21,7 +21,7 @@ func (app *App) handleGenerateTokens(w http.ResponseWriter, r *http.Request) {
|
|||
// handleExportTokenLinks streams a CSV download with token signup links,
|
||||
// compatible with MailChimp / Zeffy bulk-send workflows.
|
||||
func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -37,55 +37,62 @@ func (app *App) handleExportTokenLinks(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("Content-Disposition", `attachment; filename="volunteer-tokens.csv"`)
|
||||
wr := csv.NewWriter(w)
|
||||
wr.Write([]string{"Email Address", "First Name", "Token", "Signup Link"})
|
||||
for _, a := range attendees {
|
||||
if a.VolunteerToken == nil {
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
continue
|
||||
}
|
||||
firstName := a.Name
|
||||
if parts := strings.Fields(a.Name); len(parts) > 0 {
|
||||
p, _ := app.getParticipant(*tk.ParticipantID)
|
||||
if p == nil || p.Email == "" {
|
||||
continue
|
||||
}
|
||||
firstName := p.PreferredName
|
||||
if firstName == "" {
|
||||
firstName = tk.Name
|
||||
}
|
||||
if parts := strings.Fields(firstName); len(parts) > 0 {
|
||||
firstName = parts[0]
|
||||
}
|
||||
link := fmt.Sprintf("%s/#/v/%s", baseURL, *a.VolunteerToken)
|
||||
wr.Write([]string{a.Email, firstName, *a.VolunteerToken, link})
|
||||
link := fmt.Sprintf("%s/v/%s", baseURL, *tk.Code)
|
||||
wr.Write([]string{p.Email, firstName, *tk.Code, link})
|
||||
}
|
||||
wr.Flush()
|
||||
}
|
||||
|
||||
// handleEmailToken sends a token email to a single attendee.
|
||||
// handleEmailToken sends a token email to a single ticket's participant.
|
||||
func (app *App) handleEmailToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a, err := app.getAttendee(id)
|
||||
if err != nil || a == nil {
|
||||
tk, err := app.getTicket(id)
|
||||
if err != nil || tk == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := app.sendTokenEmail(*a); err != nil {
|
||||
if err := app.sendTicketTokenEmail(*tk); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleEmailAllTokens bulk-sends token emails to all attendees that have both a token and email.
|
||||
// handleEmailAllTokens bulk-sends token emails to all tickets that have a code and participant email.
|
||||
func (app *App) handleEmailAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||
attendees, err := app.listAttendees("", "", "")
|
||||
tickets, err := app.listTickets(nil, "")
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var sent, skipped int
|
||||
var errors []string
|
||||
for _, a := range attendees {
|
||||
if a.Email == "" || a.VolunteerToken == nil {
|
||||
for _, tk := range tickets {
|
||||
if tk.Code == nil || tk.ParticipantID == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := app.sendTokenEmail(a); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", a.Name, err))
|
||||
if err := app.sendTicketTokenEmail(tk); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("ticket %d: %v", tk.ID, err))
|
||||
skipped++
|
||||
} else {
|
||||
sent++
|
||||
|
|
|
|||
|
|
@ -17,17 +17,18 @@ func (app *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Roles []string `json:"roles"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Username == "" || body.Password == "" || body.Role == "" {
|
||||
writeError(w, "username, password, and role are required", http.StatusBadRequest)
|
||||
if body.Email == "" || body.Password == "" || len(body.Roles) == 0 {
|
||||
writeError(w, "email, password, and at least one role are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(body.Password)
|
||||
|
|
@ -38,7 +39,7 @@ func (app *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if body.DepartmentIDs == nil {
|
||||
body.DepartmentIDs = []int{}
|
||||
}
|
||||
user, err := app.createUser(body.Username, hash, body.Role, body.DepartmentIDs)
|
||||
user, err := app.createUser(body.Email, body.PreferredName, hash, body.Roles, body.DepartmentIDs)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -53,8 +54,13 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target, _ := app.getUser(id)
|
||||
if target == nil {
|
||||
writeError(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
Roles []string `json:"roles"`
|
||||
Password string `json:"password"`
|
||||
DepartmentIDs []int `json:"department_ids"`
|
||||
}
|
||||
|
|
@ -65,8 +71,8 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if body.DepartmentIDs == nil {
|
||||
body.DepartmentIDs = []int{}
|
||||
}
|
||||
if body.Role != "" {
|
||||
if err := app.updateUser(id, body.Role, body.DepartmentIDs); err != nil {
|
||||
if body.Roles != nil {
|
||||
if err := app.updateUserRoles(id, body.Roles, body.DepartmentIDs); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -82,7 +88,7 @@ func (app *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
user, _ := app.getUserByID(id)
|
||||
user, _ := app.getUser(id)
|
||||
writeJSON(w, user)
|
||||
}
|
||||
|
||||
|
|
@ -93,11 +99,11 @@ func (app *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.UserID == id {
|
||||
if claims.ParticipantID == id {
|
||||
writeError(w, "cannot delete yourself", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.deleteUser(id); err != nil {
|
||||
if err := app.removeUser(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
|
@ -11,20 +12,19 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
search := q.Get("search")
|
||||
since := q.Get("since")
|
||||
|
||||
var deptID *int
|
||||
var deptIDs []int
|
||||
if d := q.Get("dept"); d != "" {
|
||||
id, err := strconv.Atoi(d)
|
||||
if err == nil {
|
||||
deptID = &id
|
||||
if id, err := strconv.Atoi(d); err == nil {
|
||||
deptIDs = []int{id}
|
||||
}
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" && deptID == nil && len(claims.DeptIDs) > 0 {
|
||||
deptID = &claims.DeptIDs[0]
|
||||
if isCoLeadOnly(claims) && len(deptIDs) == 0 {
|
||||
deptIDs = claims.DeptIDs
|
||||
}
|
||||
|
||||
volunteers, err := app.listVolunteers(search, deptID, since)
|
||||
volunteers, err := app.listVolunteers(search, deptIDs, since)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -33,27 +33,65 @@ func (app *App) handleListVolunteers(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
var v Volunteer
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
TicketName string `json:"ticket_name"`
|
||||
Email string `json:"email"`
|
||||
DepartmentID *int `json:"department_id"`
|
||||
IsLead bool `json:"is_lead"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
if body.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Email == "" {
|
||||
writeError(w, "email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
if isCoLeadOnly(claims) {
|
||||
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
p, _ := app.getParticipantByEmail(body.Email)
|
||||
if p == nil {
|
||||
p, _ = app.createParticipant(Participant{PreferredName: body.Name, Email: body.Email, TicketName: body.TicketName})
|
||||
} else if body.TicketName != "" && p.TicketName == "" {
|
||||
app.db.Exec(`UPDATE participants SET ticket_name = ?, updated_at = ? WHERE id = ?`, body.TicketName, now(), p.ID)
|
||||
}
|
||||
if p == nil {
|
||||
writeError(w, "failed to create participant", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
confirmToken, err := generateConfirmationToken()
|
||||
if err != nil {
|
||||
writeError(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
app.setParticipantConfirmationToken(p.ID, confirmToken)
|
||||
v := Volunteer{
|
||||
ParticipantID: p.ID,
|
||||
DepartmentID: body.DepartmentID,
|
||||
IsLead: body.IsLead,
|
||||
Note: body.Note,
|
||||
}
|
||||
created, err := app.createVolunteer(v)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := app.sendConfirmationEmail(body.Email, body.Name, confirmToken); err != nil {
|
||||
log.Printf("confirmation email to %s failed: %v", body.Email, err)
|
||||
}
|
||||
}()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, created)
|
||||
}
|
||||
|
|
@ -78,28 +116,40 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var v Volunteer
|
||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
||||
var body struct {
|
||||
DepartmentID *int `json:"department_id"`
|
||||
IsLead bool `json:"is_lead"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if claims.Role == "volunteer_lead" {
|
||||
if isCoLeadOnly(claims) {
|
||||
existing, _ := app.getVolunteer(id)
|
||||
if existing == nil || existing.DepartmentID == nil || !inSlice(*existing.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if body.DepartmentID != nil && !inSlice(*body.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: cannot move volunteer to that department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v := Volunteer{
|
||||
ID: id,
|
||||
DepartmentID: body.DepartmentID,
|
||||
IsLead: body.IsLead,
|
||||
Note: body.Note,
|
||||
}
|
||||
v.ID = id
|
||||
if err := app.updateVolunteer(v); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if v.IsLead {
|
||||
app.confirmVolunteer(id)
|
||||
}
|
||||
updated, _ := app.getVolunteer(id)
|
||||
writeJSON(w, updated)
|
||||
}
|
||||
|
|
@ -110,6 +160,14 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.deleteVolunteer(id); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -117,14 +175,21 @@ func (app *App) handleDeleteVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
v, err := app.checkInVolunteer(id, claims.UserID)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := app.markVolunteerReady(id, claims.ParticipantID)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -133,6 +198,28 @@ func (app *App) handleCheckInVolunteer(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
func (app *App) handleConfirmVolunteer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(id)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := app.confirmVolunteer(id)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
||||
volunteerID, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
|
@ -146,7 +233,24 @@ func (app *App) handleAssignShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "shift_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := app.assignShift(volunteerID, body.ShiftID); err != nil {
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(volunteerID)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
shift, err := app.getShift(body.ShiftID)
|
||||
if err != nil || shift == nil {
|
||||
writeError(w, "shift not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := app.assignShiftWithCapacity(volunteerID, body.ShiftID, shift.Capacity); err != nil {
|
||||
if err == errShiftFull {
|
||||
writeError(w, "shift is at capacity", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -164,6 +268,14 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, "invalid shift id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
claims := claimsFromContext(r)
|
||||
if isCoLeadOnly(claims) {
|
||||
v, _ := app.getVolunteer(volunteerID)
|
||||
if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
|
||||
writeError(w, "forbidden: outside your department", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := app.unassignShift(volunteerID, shiftID); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -171,11 +283,3 @@ func (app *App) handleUnassignShift(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func inSlice(v int, s []int) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
264
handle_volunteers_test.go
Normal file
264
handle_volunteers_test.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
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,6 +12,8 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
var buildID = "dev"
|
||||
|
||||
//go:embed frontend/dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
|
|
@ -97,39 +99,44 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/event", auth(app.handleGetEvent))
|
||||
mux.HandleFunc("PUT /api/event", auth(app.handleUpdateEvent, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/attendees", auth(app.handleListAttendees, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees", auth(app.handleCreateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export", auth(app.handleExportAttendees, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/generate-tokens", auth(app.handleGenerateTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/export-tokens", auth(app.handleExportTokenLinks, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/email-tokens", auth(app.handleEmailAllTokens, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/attendees/{id}", auth(app.handleGetAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("PUT /api/attendees/{id}", auth(app.handleUpdateAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("DELETE /api/attendees/{id}", auth(app.handleDeleteAttendee, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/checkin", auth(app.handleCheckInAttendee, "admin", "ticketing", "gate"))
|
||||
mux.HandleFunc("POST /api/attendees/{id}/email-token", auth(app.handleEmailToken, "admin", "ticketing"))
|
||||
mux.HandleFunc("GET /api/participants", auth(app.handleListParticipants, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/participants", auth(app.handleCreateParticipant, "admin"))
|
||||
mux.HandleFunc("GET /api/participants/export", auth(app.handleExportParticipants, "admin"))
|
||||
mux.HandleFunc("GET /api/participants/{id}", auth(app.handleGetParticipant, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("PUT /api/participants/{id}", auth(app.handleUpdateParticipant, "admin"))
|
||||
mux.HandleFunc("DELETE /api/participants/{id}", auth(app.handleDeleteParticipant, "admin"))
|
||||
mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "gatekeeper"))
|
||||
mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin"))
|
||||
mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/email-codes", auth(app.handleEmailAllTokens, "admin"))
|
||||
mux.HandleFunc("POST /api/tickets/{id}/email-code", auth(app.handleEmailToken, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/departments", auth(app.handleListDepartments))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "coordinator"))
|
||||
mux.HandleFunc("POST /api/departments", auth(app.handleCreateDepartment, "admin", "staffing"))
|
||||
mux.HandleFunc("PUT /api/departments/{id}", auth(app.handleUpdateDepartment, "admin", "staffing"))
|
||||
mux.HandleFunc("DELETE /api/departments/{id}", auth(app.handleDeleteDepartment, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/volunteers", auth(app.handleListVolunteers, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers", auth(app.handleCreateVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/confirm", auth(app.handleConfirmVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/shifts", auth(app.handleAssignShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "staffing", "colead"))
|
||||
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "coordinator", "volunteer_lead"))
|
||||
mux.HandleFunc("GET /api/shifts", auth(app.handleListShifts, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts", auth(app.handleCreateShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/reorder", auth(app.handleReorderShifts, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/shifts/{id}", auth(app.handleUpdateShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}", auth(app.handleDeleteShift, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/shifts/{id}/volunteers", auth(app.handleAssignShiftVolunteer, "admin", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/shifts/{id}/volunteers/{volunteer_id}", auth(app.handleUnassignShiftVolunteer, "admin", "staffing", "colead"))
|
||||
|
||||
mux.HandleFunc("GET /api/users", auth(app.handleListUsers, "admin"))
|
||||
mux.HandleFunc("POST /api/users", auth(app.handleCreateUser, "admin"))
|
||||
|
|
@ -139,12 +146,31 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/settings", auth(app.handleGetSettings, "admin"))
|
||||
mux.HandleFunc("PUT /api/settings", auth(app.handleUpdateSettings, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/test-email", auth(app.handleTestEmail, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-tickets", auth(app.handleResetTickets, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteers", auth(app.handleResetVolunteers, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-shifts", auth(app.handleResetShifts, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-departments", auth(app.handleResetDepartments, "admin"))
|
||||
mux.HandleFunc("POST /api/settings/reset-volunteer-shifts", auth(app.handleResetVolunteerShifts, "admin"))
|
||||
|
||||
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin", "ticketing"))
|
||||
mux.HandleFunc("POST /api/import", auth(app.handleImport, "admin"))
|
||||
|
||||
mux.HandleFunc("GET /api/sync/pull", auth(app.handleSyncPull))
|
||||
mux.HandleFunc("GET /api/sync/stream", auth(app.handleSyncStream))
|
||||
|
||||
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.
|
||||
mux.HandleFunc("GET /api/v/{token}", app.handleKioskGet)
|
||||
mux.HandleFunc("POST /api/v/{token}/shifts/{id}", app.handleKioskClaim)
|
||||
|
|
@ -173,9 +199,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
}
|
||||
|
||||
func (app *App) bootstrapAdmin() error {
|
||||
adminUser := os.Getenv("TURNPIKE_ADMIN_USER")
|
||||
adminEmail := os.Getenv("TURNPIKE_ADMIN_EMAIL")
|
||||
adminPass := os.Getenv("TURNPIKE_ADMIN_PASSWORD")
|
||||
if adminUser == "" || adminPass == "" {
|
||||
if adminEmail == "" || adminPass == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := app.countUsers()
|
||||
|
|
@ -186,11 +212,11 @@ func (app *App) bootstrapAdmin() error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = app.createUser(adminUser, hash, "admin", []int{})
|
||||
_, err = app.createUser(adminEmail, "Admin", hash, []string{"admin"}, []int{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Created admin user: %s", adminUser)
|
||||
log.Printf("Created admin user: %s", adminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
92
testutil_test.go
Normal file
92
testutil_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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