Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 40ebaf07bf |
7 changed files with 48 additions and 28 deletions
46
db.go
46
db.go
|
|
@ -93,8 +93,8 @@ func migrate(db *sql.DB) error {
|
|||
phone TEXT NOT NULL DEFAULT '',
|
||||
department_id INTEGER REFERENCES departments(id) ON DELETE SET NULL,
|
||||
is_lead INTEGER NOT NULL DEFAULT 0,
|
||||
checked_in INTEGER NOT NULL DEFAULT 0,
|
||||
checked_in_at TEXT,
|
||||
ready INTEGER NOT NULL DEFAULT 0,
|
||||
ready_at TEXT,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
|
|
@ -177,6 +177,8 @@ func migrateV2(db *sql.DB) error {
|
|||
addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
|
||||
addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
|
||||
addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
|
||||
renameColumnIfExists(db, "volunteers", "checked_in", "ready")
|
||||
renameColumnIfExists(db, "volunteers", "checked_in_at", "ready_at")
|
||||
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
|
||||
// Migrate kiosk codes from tickets to volunteers (idempotent).
|
||||
db.Exec(`
|
||||
|
|
@ -340,6 +342,24 @@ func addColumnIfMissing(db *sql.DB, table, colDef string) {
|
|||
db.Exec(`ALTER TABLE "` + table + `" ADD COLUMN ` + colDef)
|
||||
}
|
||||
|
||||
func renameColumnIfExists(db *sql.DB, table, oldName, newName string) {
|
||||
rows, err := db.Query(`PRAGMA table_info("` + table + `")`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var dflt sql.NullString
|
||||
rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk)
|
||||
if name == oldName {
|
||||
db.Exec(`ALTER TABLE "` + table + `" RENAME COLUMN "` + oldName + `" TO "` + newName + `"`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
const attendeeCols = `id, name, email, phone, ticket_id, ticket_type, volunteer_token,
|
||||
|
|
@ -407,8 +427,8 @@ type Volunteer struct {
|
|||
Pronouns string `json:"pronouns"`
|
||||
DepartmentID *int `json:"department_id,omitempty"`
|
||||
IsLead bool `json:"is_lead"`
|
||||
CheckedIn bool `json:"checked_in"`
|
||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
ReadyAt *string `json:"ready_at,omitempty"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
ConfirmedAt *string `json:"confirmed_at,omitempty"`
|
||||
EmailConfirmed bool `json:"email_confirmed"`
|
||||
|
|
@ -1203,14 +1223,14 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
|
|||
COALESCE(NULLIF(p.email,''), v.email),
|
||||
COALESCE(NULLIF(p.phone,''), v.phone),
|
||||
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
|
||||
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
|
||||
v.department_id, v.is_lead, v.ready, v.ready_at,
|
||||
v.confirmed, v.confirmed_at,
|
||||
v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
|
||||
v.created_at, v.updated_at, v.deleted_at`
|
||||
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
|
||||
|
||||
// volunteerCols is kept for backward-compat references that expect unqualified column names.
|
||||
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, checked_in, checked_in_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
||||
const volunteerCols = `id, attendee_id, name, preferred_name, email, phone, pronouns, department_id, is_lead, ready, ready_at, email_confirmed, confirmation_token, note, created_at, updated_at, deleted_at`
|
||||
|
||||
func (app *App) listVolunteers(search string, deptID *int, since string) ([]Volunteer, error) {
|
||||
q := `SELECT ` + volunteerSelect + ` ` + volunteerFrom + ` WHERE 1=1`
|
||||
|
|
@ -1292,13 +1312,13 @@ func (app *App) deleteVolunteer(id int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// checkInVolunteer marks the volunteer as checked in and, if linked to an attendee,
|
||||
// markVolunteerReady marks the volunteer as ready and, if linked to an attendee,
|
||||
// also increments the attendee's checked_in_count.
|
||||
func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
|
||||
func (app *App) markVolunteerReady(id, userID int) (*Volunteer, error) {
|
||||
t := now()
|
||||
_, err := app.db.Exec(
|
||||
`UPDATE volunteers SET checked_in=1, checked_in_at=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL AND checked_in=0`,
|
||||
`UPDATE volunteers SET ready=1, ready_at=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL AND ready=0`,
|
||||
t, t, id,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -1337,12 +1357,12 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|||
for rows.Next() {
|
||||
var v Volunteer
|
||||
var participantID, attendeeID, deptID sql.NullInt64
|
||||
var isLead, checkedIn, confirmed, emailConfirmed int
|
||||
var isLead, ready, confirmed, emailConfirmed int
|
||||
var confirmationToken, confirmedAt, kioskCode sql.NullString
|
||||
if err := rows.Scan(
|
||||
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName,
|
||||
&v.Email, &v.Phone, &v.Pronouns, &deptID,
|
||||
&isLead, &checkedIn, &v.CheckedInAt,
|
||||
&isLead, &ready, &v.ReadyAt,
|
||||
&confirmed, &confirmedAt,
|
||||
&emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
|
||||
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
|
||||
|
|
@ -1371,7 +1391,7 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
|
|||
v.KioskCode = &kioskCode.String
|
||||
}
|
||||
v.IsLead = isLead == 1
|
||||
v.CheckedIn = checkedIn == 1
|
||||
v.Ready = ready == 1
|
||||
v.Confirmed = confirmed == 1
|
||||
v.EmailConfirmed = emailConfirmed == 1
|
||||
result = append(result, v)
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export const api = {
|
|||
create: (data) => apiJSON('/api/volunteers', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => apiJSON(`/api/volunteers/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id) => apiFetch(`/api/volunteers/${id}`, { method: 'DELETE' }),
|
||||
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' }),
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
</script>
|
||||
|
||||
<button class="btn btn-success btn-sm" onclick={handle} disabled={loading}>
|
||||
{loading ? '…' : '✓ Ready'}
|
||||
{loading ? '…' : 'Mark ready'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
return vols
|
||||
})
|
||||
const volTotal = $derived(volunteers.length)
|
||||
const volCheckedIn = $derived(volunteers.filter(v => v.checked_in).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)
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@
|
|||
if (filterDept && v.department_id !== parseInt(filterDept)) return false
|
||||
if (filterStatus === 'unconfirmed' && v.email_confirmed) return false
|
||||
if (filterStatus === 'registered' && (!v.email_confirmed || v.confirmed)) return false
|
||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.checked_in)) return false
|
||||
if (filterStatus === 'ready' && !v.checked_in) return false
|
||||
if (filterStatus === 'confirmed' && (!v.confirmed || v.ready)) return false
|
||||
if (filterStatus === 'ready' && !v.ready) return false
|
||||
if (s && !v.name.toLowerCase().includes(s) &&
|
||||
!(v.email || '').toLowerCase().includes(s)) return false
|
||||
return true
|
||||
|
|
@ -63,9 +63,9 @@
|
|||
.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
|
||||
|
|
@ -314,7 +314,7 @@
|
|||
{/if}
|
||||
</td>
|
||||
<td class="td-status">
|
||||
{#if v.checked_in}
|
||||
{#if v.ready}
|
||||
<span class="badge badge-checked">Ready</span>
|
||||
{:else if v.confirmed}
|
||||
<span class="badge badge-confirmed">Confirmed</span>
|
||||
|
|
@ -323,15 +323,15 @@
|
|||
{:else}
|
||||
<span class="badge badge-unchecked">Unconfirmed</span>
|
||||
{/if}
|
||||
{#if v.checked_in_at}
|
||||
{#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 class="td-ready">
|
||||
{#if !v.checked_in}
|
||||
<CheckInButton onclick={() => checkIn(v)} />
|
||||
{#if v.confirmed && !v.ready}
|
||||
<CheckInButton onclick={() => markReady(v)} />
|
||||
{/if}
|
||||
</td>
|
||||
{#if canManage}
|
||||
|
|
|
|||
|
|
@ -130,14 +130,14 @@ 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)
|
||||
v, err := app.markVolunteerReady(id, claims.UserID)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -125,7 +125,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("GET /api/volunteers/{id}", auth(app.handleGetVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("PUT /api/volunteers/{id}", auth(app.handleUpdateVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}", auth(app.handleDeleteVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/checkin", auth(app.handleCheckInVolunteer, "admin", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("POST /api/volunteers/{id}/ready", auth(app.handleMarkVolunteerReady, "admin", "ticketing", "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", "ticketing", "staffing", "colead"))
|
||||
mux.HandleFunc("DELETE /api/volunteers/{id}/shifts/{shift_id}", auth(app.handleUnassignShift, "admin", "ticketing", "staffing", "colead"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue