diff --git a/auth.go b/auth.go
index 0cc812a..b675e6f 100644
--- a/auth.go
+++ b/auth.go
@@ -108,6 +108,20 @@ func hasAnyRole(roles []string, allowed []string) bool {
return false
}
+func isCoLeadOnly(claims *Claims) bool {
+ return hasAnyRole(claims.Roles, []string{"colead"}) &&
+ !hasAnyRole(claims.Roles, []string{"admin", "staffing"})
+}
+
+func inSlice(v int, s []int) bool {
+ for _, x := range s {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
func claimsFromContext(r *http.Request) *Claims {
c, _ := r.Context().Value(claimsKey).(*Claims)
return c
diff --git a/db.go b/db.go
index 99b335c..7857020 100644
--- a/db.go
+++ b/db.go
@@ -939,6 +939,8 @@ func (app *App) mergeParticipants(canonicalID, otherID int) error {
); err != nil {
return err
}
+ app.db.Exec(`INSERT OR IGNORE INTO participant_roles (participant_id, role) SELECT ?, role FROM participant_roles WHERE participant_id = ?`, canonicalID, otherID)
+ app.db.Exec(`INSERT OR IGNORE INTO participant_departments (participant_id, department_id) SELECT ?, department_id FROM participant_departments WHERE participant_id = ?`, canonicalID, otherID)
_, err := app.db.Exec(
`UPDATE participants SET deleted_at=?, updated_at=? WHERE id=?`, ts, ts, otherID,
)
@@ -1206,7 +1208,7 @@ const volunteerSelect = `v.id, v.participant_id,
v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v INNER JOIN participants p ON p.id = v.participant_id`
-func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
+func (app *App) listVolunteers(search string, deptIDs []int, since string) ([]Volunteer, error) {
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
var args []any
if since != "" {
@@ -1220,9 +1222,14 @@ func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volu
s := "%" + search + "%"
args = append(args, s, s)
}
- if deptID != nil {
+ if len(deptIDs) == 1 {
q += ` AND v.department_id = ?`
- args = append(args, *deptID)
+ args = append(args, deptIDs[0])
+ } else if len(deptIDs) > 1 {
+ q += ` AND v.department_id IN (` + placeholders(len(deptIDs)) + `)`
+ for _, id := range deptIDs {
+ args = append(args, id)
+ }
}
q += ` ORDER BY p.preferred_name`
return queryVolunteers(app.db, q, args...)
@@ -1422,7 +1429,7 @@ func generateConfirmationToken() (string, error) {
// --- Shifts ---
-func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
+func (app *App) listShifts(deptIDs []int, day, since string) ([]Shift, error) {
q := `SELECT ` + shiftCols + ` FROM shifts WHERE 1=1`
var args []any
if since != "" {
@@ -1431,9 +1438,14 @@ func (app *App) listShifts(deptID *int, day, since string) ([]Shift, error) {
} else {
q += ` AND deleted_at IS NULL`
}
- if deptID != nil {
+ if len(deptIDs) == 1 {
q += ` AND department_id = ?`
- args = append(args, *deptID)
+ args = append(args, deptIDs[0])
+ } else if len(deptIDs) > 1 {
+ q += ` AND department_id IN (` + placeholders(len(deptIDs)) + `)`
+ for _, id := range deptIDs {
+ args = append(args, id)
+ }
}
if day != "" {
q += ` AND day = ?`
@@ -1669,3 +1681,10 @@ func boolInt(b bool) int {
}
return 0
}
+
+func placeholders(n int) string {
+ if n <= 0 {
+ return ""
+ }
+ return strings.Repeat("?,", n-1) + "?"
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 52c6741..a1fa253 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -84,7 +84,6 @@
const path = $derived(route || '/')
const roles = $derived(session?.user?.roles ?? [])
- function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
{#if updateAvailable}
@@ -125,7 +124,7 @@
{#if roles.length === 1 && roles[0] === 'colead'}
{:else}
-
+
{/if}
{:else if path.startsWith('/participants')}
diff --git a/frontend/src/db.js b/frontend/src/db.js
index bfddf9f..bd3b490 100644
--- a/frontend/src/db.js
+++ b/frontend/src/db.js
@@ -80,6 +80,18 @@ export async function saveSession(token, user) {
}
export async function clearSession() {
- await db.session.clear()
- await db.meta.clear()
+ 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()
+ }
+ )
}
diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte
index 0f6decc..9756b31 100644
--- a/frontend/src/pages/Dashboard.svelte
+++ b/frontend/src/pages/Dashboard.svelte
@@ -2,7 +2,7 @@
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)) }
@@ -147,14 +147,14 @@
{#if isAdmin}
{:else if isStaffing || isColead}
{/if}
diff --git a/frontend/src/pages/ScheduleBoard.svelte b/frontend/src/pages/ScheduleBoard.svelte
index 922a0b0..6bea05e 100644
--- a/frontend/src/pages/ScheduleBoard.svelte
+++ b/frontend/src/pages/ScheduleBoard.svelte
@@ -135,11 +135,13 @@
try {
const res = await api.shifts.reorder(positions)
- if (res && !res.ok) throw new Error()
- for (const p of positions) {
- const s = await db.shifts.get(p.id)
- if (s) await db.shifts.put({ ...s, position: p.position })
- }
+ 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
}
diff --git a/frontend/src/sync.js b/frontend/src/sync.js
index e2c4bff..ef22313 100644
--- a/frontend/src/sync.js
+++ b/frontend/src/sync.js
@@ -22,10 +22,10 @@ async function checkBuildChanged() {
await db.volunteers.clear()
await db.shifts.clear()
await db.volunteer_shifts.clear()
+ await db.meta.put({ key: 'build', value: build })
}
)
}
- await db.meta.put({ key: 'build', value: build })
} catch {}
}
diff --git a/handle_settings.go b/handle_settings.go
index d4ed01c..2c0c991 100644
--- a/handle_settings.go
+++ b/handle_settings.go
@@ -58,7 +58,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var val string
switch vv := v.(type) {
case string:
- if k == "smtp_password" && vv == "" {
+ if k == "smtp_password" && (vv == "" || vv == "***") {
continue
}
val = vv
diff --git a/handle_shifts.go b/handle_shifts.go
index 67312c5..9299916 100644
--- a/handle_shifts.go
+++ b/handle_shifts.go
@@ -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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && 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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && !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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) {
+ 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}
diff --git a/handle_shifts_test.go b/handle_shifts_test.go
index 940164e..19c49bf 100644
--- a/handle_shifts_test.go
+++ b/handle_shifts_test.go
@@ -104,6 +104,86 @@ func TestShiftAssignConflict(t *testing.T) {
}
}
+func TestCoLeadDeleteShiftOtherDept(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ deptA, _ := app.createDepartment(Department{Name: "Gate"})
+ deptB, _ := app.createDepartment(Department{Name: "Build"})
+ colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
+ tok := testToken(t, app, colead)
+
+ s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected 403 for other dept, got %d", w.Code)
+ }
+}
+
+func TestCoLeadDeleteShiftOwnDept(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ deptA, _ := app.createDepartment(Department{Name: "Gate"})
+ colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
+ tok := testToken(t, app, colead)
+
+ s, _ := app.createShift(Shift{DepartmentID: deptA.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testAuthRequest("DELETE", "/api/shifts/"+itoa(s.ID), nil, tok))
+ if w.Code != http.StatusNoContent {
+ t.Errorf("expected 204 for own dept, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestCoLeadAssignShiftVolunteerOtherDept(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ deptA, _ := app.createDepartment(Department{Name: "Gate"})
+ deptB, _ := app.createDepartment(Department{Name: "Build"})
+ colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
+ tok := testToken(t, app, colead)
+
+ s, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "AM", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
+ deptBID := deptB.ID
+ p, _ := app.createParticipant(Participant{PreferredName: "Puck", Email: "puck@test.com"})
+ v, _ := app.createVolunteer(Volunteer{ParticipantID: p.ID, DepartmentID: &deptBID})
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/"+itoa(s.ID)+"/volunteers", map[string]any{
+ "volunteer_id": v.ID,
+ }, tok))
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected 403 for other dept, got %d", w.Code)
+ }
+}
+
+func TestCoLeadReorderShiftsOtherDept(t *testing.T) {
+ app := testApp(t)
+ mux := testMux(app)
+
+ deptA, _ := app.createDepartment(Department{Name: "Gate"})
+ deptB, _ := app.createDepartment(Department{Name: "Build"})
+ colead := testUserWithRoles(t, app, "Hermia", []string{"colead"}, []int{deptA.ID})
+ tok := testToken(t, app, colead)
+
+ s1, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "A", Day: "2026-03-15", StartTime: "08:00", EndTime: "12:00"})
+ s2, _ := app.createShift(Shift{DepartmentID: deptB.ID, Name: "B", Day: "2026-03-15", StartTime: "12:00", EndTime: "16:00"})
+
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, testAuthRequest("POST", "/api/shifts/reorder", []map[string]int{
+ {"id": s1.ID, "position": 2},
+ {"id": s2.ID, "position": 1},
+ }, tok))
+ if w.Code != http.StatusForbidden {
+ t.Errorf("expected 403 for other dept reorder, got %d", w.Code)
+ }
+}
+
func TestShiftReorder(t *testing.T) {
app := testApp(t)
admin := testAdminUser(t, app)
diff --git a/handle_volunteers.go b/handle_volunteers.go
index ec3a317..cd891d2 100644
--- a/handle_volunteers.go
+++ b/handle_volunteers.go
@@ -12,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 hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) && 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
@@ -55,7 +54,7 @@ func (app *App) handleCreateVolunteer(w http.ResponseWriter, r *http.Request) {
return
}
claims := claimsFromContext(r)
- if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) {
+ if isCoLeadOnly(claims) {
if body.DepartmentID == nil || !inSlice(*body.DepartmentID, claims.DeptIDs) {
writeError(w, "forbidden: outside your department", http.StatusForbidden)
return
@@ -127,12 +126,16 @@ func (app *App) handleUpdateVolunteer(w http.ResponseWriter, r *http.Request) {
return
}
claims := claimsFromContext(r)
- if hasAnyRole(claims.Roles, []string{"colead"}) && !hasAnyRole(claims.Roles, []string{"admin", "staffing"}) {
+ 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,
@@ -157,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
@@ -171,6 +182,13 @@ func (app *App) handleMarkVolunteerReady(w http.ResponseWriter, r *http.Request)
return
}
claims := claimsFromContext(r)
+ if isCoLeadOnly(claims) {
+ v, _ := app.getVolunteer(id)
+ if v == nil || v.DepartmentID == nil || !inSlice(*v.DepartmentID, claims.DeptIDs) {
+ writeError(w, "forbidden: outside your department", http.StatusForbidden)
+ return
+ }
+ }
v, err := app.markVolunteerReady(id, claims.ParticipantID)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -186,6 +204,14 @@ func (app *App) handleConfirmVolunteer(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
+ }
+ }
v, err := app.confirmVolunteer(id)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -207,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
}
@@ -225,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
@@ -232,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
-}
diff --git a/handle_volunteers_test.go b/handle_volunteers_test.go
index 19ff1b0..dc61f28 100644
--- a/handle_volunteers_test.go
+++ b/handle_volunteers_test.go
@@ -79,6 +79,130 @@ func TestConfirmVolunteerRequiresRole(t *testing.T) {
}
}
+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)