diff --git a/apps/api/internal/database/migrations/20261017170000_workshop_attendance_unique.sql b/apps/api/internal/database/migrations/20261017170000_workshop_attendance_unique.sql new file mode 100644 index 00000000..0cf1dbd9 --- /dev/null +++ b/apps/api/internal/database/migrations/20261017170000_workshop_attendance_unique.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Abort rather than discard attendance if existing duplicate rows need review. +CREATE UNIQUE INDEX IF NOT EXISTS nfc_tags_workshops_user_workshop_unique +ON nfc_tags_workshops(user_id,workshop_id); + +-- +goose Down +DROP INDEX IF EXISTS nfc_tags_workshops_user_workshop_unique; diff --git a/apps/api/internal/database/repository/workshop_attendance.go b/apps/api/internal/database/repository/workshop_attendance.go new file mode 100644 index 00000000..da0e1080 --- /dev/null +++ b/apps/api/internal/database/repository/workshop_attendance.go @@ -0,0 +1,90 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var ErrWorkshopAttendanceNotFound = errors.New("workshop or hacker not found") +var ErrWorkshopAttendanceIneligible = errors.New("hacker must be confirmed and checked in") + +type WorkshopAttendance struct { + Name string `json:"name"` + WorkshopTitle string `json:"workshopTitle"` + AlreadyRecorded bool `json:"alreadyRecorded"` +} + +type AttendanceWorkshop struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + StartTime time.Time `json:"startTime"` + CheckedInCount int64 `json:"checkedInCount"` +} + +func (r *WorkshopsRepository) AttendanceWorkshops(ctx context.Context) ([]AttendanceWorkshop, error) { + rows, err := r.db.Pool.Query(ctx, `SELECT w.id,w.title,w.type,w.start_time,count(DISTINCT a.user_id) +FROM workshops w +LEFT JOIN nfc_tags_workshops a ON a.workshop_id=w.id +GROUP BY w.id,w.title,w.type,w.start_time +ORDER BY w.start_time,w.title,w.id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []AttendanceWorkshop{} + for rows.Next() { + var w AttendanceWorkshop + if err := rows.Scan(&w.ID, &w.Title, &w.Type, &w.StartTime, &w.CheckedInCount); err != nil { + return nil, err + } + result = append(result, w) + } + return result, rows.Err() +} + +// QR and NFC attendance share the same (user_id, workshop_id) uniqueness rule. +// Registration/RSVP counts are deliberately not changed by an attendance scan. +func (r *WorkshopsRepository) RecordAttendance(ctx context.Context, workshopID, userID uuid.UUID) (*WorkshopAttendance, error) { + tx, err := r.db.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) //nolint:errcheck + result := &WorkshopAttendance{} + err = tx.QueryRow(ctx, `SELECT title FROM workshops WHERE id=$1 FOR KEY SHARE`, workshopID).Scan(&result.WorkshopTitle) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrWorkshopAttendanceNotFound + } + if err != nil { + return nil, err + } + var eligible bool + err = tx.QueryRow(ctx, `SELECT COALESCE(NULLIF(trim(concat_ws(' ',a.application->>'firstName',a.application->>'lastName')),''),u.name), + a.status::text='confirmed' AND NOT a.is_fake AND NOT u.is_fake + AND (u.checked_in_at IS NOT NULL OR EXISTS(SELECT 1 FROM nfc_tags_user n WHERE n.user_id=u.id)) + FROM users u JOIN applications a ON a.user_id=u.id JOIN hackathons h ON h.id=a.hackathon_id AND h.is_active + WHERE u.id=$1 FOR UPDATE OF u,a`, userID).Scan(&result.Name, &eligible) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrWorkshopAttendanceNotFound + } + if err != nil { + return nil, err + } + if !eligible { + return nil, ErrWorkshopAttendanceIneligible + } + tag, err := tx.Exec(ctx, `INSERT INTO nfc_tags_workshops (user_id,workshop_id) VALUES ($1,$2) ON CONFLICT (user_id,workshop_id) DO NOTHING`, userID, workshopID) + if err != nil { + return nil, err + } + result.AlreadyRecorded = tag.RowsAffected() == 0 + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} diff --git a/apps/api/internal/database/repository/workshop_attendance_test.go b/apps/api/internal/database/repository/workshop_attendance_test.go new file mode 100644 index 00000000..0adfb8f5 --- /dev/null +++ b/apps/api/internal/database/repository/workshop_attendance_test.go @@ -0,0 +1,150 @@ +package repository + +import ( + "context" + "errors" + "os" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/swamphacks/core/apps/api/internal/database" +) + +// Uses an isolated schema; never points at the event database by default. +func TestWorkshopAttendanceIntegration(t *testing.T) { + uri := os.Getenv("WORKSHOP_TEST_DATABASE_URL") + if uri == "" { + t.Skip("set WORKSHOP_TEST_DATABASE_URL to a local disposable PostgreSQL database") + } + ctx := context.Background() + config, err := pgxpool.ParseConfig(uri) + if err != nil { + t.Fatal(err) + } + if config.ConnConfig.Host != "127.0.0.1" && config.ConnConfig.Host != "localhost" { + t.Fatal("workshop tests require a local database") + } + setup, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + defer setup.Close() + schema := "workshop_test_" + uuid.NewString()[:8] + if _, err = setup.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + defer func() { _, _ = setup.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE") }() + config.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + _, err = pool.Exec(ctx, `CREATE TABLE users(id uuid PRIMARY KEY,name text,checked_in_at timestamptz,is_fake boolean DEFAULT false); + CREATE TABLE hackathons(id text PRIMARY KEY,is_active boolean); + CREATE TABLE applications(user_id uuid,hackathon_id text,status text,application jsonb DEFAULT '{}',is_fake boolean DEFAULT false); + CREATE TABLE workshops(id uuid PRIMARY KEY,title text,type text,start_time timestamptz,num_attendees integer DEFAULT 0); + CREATE TABLE nfc_tags_user(tag_id text PRIMARY KEY,user_id uuid UNIQUE); + CREATE TABLE nfc_tags_workshops(user_id uuid,workshop_id uuid,tag_id text,UNIQUE(user_id,workshop_id)); + INSERT INTO hackathons VALUES('xii',true);`) + if err != nil { + t.Fatal(err) + } + r := NewWorkshopsRepository(&database.DB{Pool: pool}) + workshop := uuid.New() + if _, err = pool.Exec(ctx, `INSERT INTO workshops(id,title,type,start_time) VALUES($1,'Workshop','workshop',now())`, workshop); err != nil { + t.Fatal(err) + } + addUser := func(status string, checkin bool, nfc bool) uuid.UUID { + t.Helper() + id := uuid.New() + _, err := pool.Exec(ctx, `INSERT INTO users(id,name,checked_in_at) VALUES($1,'Hacker',CASE WHEN $2 THEN now() ELSE NULL END)`, id, checkin) + if err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, `INSERT INTO applications(user_id,hackathon_id,status) VALUES($1,'xii',$2)`, id, status); err != nil { + t.Fatal(err) + } + if nfc { + if _, err = pool.Exec(ctx, `INSERT INTO nfc_tags_user VALUES($1,$2)`, id.String(), id); err != nil { + t.Fatal(err) + } + } + return id + } + t.Run("QR without NFC and repeat", func(t *testing.T) { + id := addUser("confirmed", true, false) + got, err := r.RecordAttendance(ctx, workshop, id) + if err != nil || got.AlreadyRecorded { + t.Fatalf("first=%+v err=%v", got, err) + } + got, err = r.RecordAttendance(ctx, workshop, id) + if err != nil || !got.AlreadyRecorded { + t.Fatalf("repeat=%+v err=%v", got, err) + } + }) + t.Run("NFC-only checkin qualifies", func(t *testing.T) { + id := addUser("confirmed", false, true) + if _, err := r.RecordAttendance(ctx, workshop, id); err != nil { + t.Fatal(err) + } + }) + t.Run("existing NFC attendance deduplicates", func(t *testing.T) { + id := addUser("confirmed", false, true) + if _, err := pool.Exec(ctx, `INSERT INTO nfc_tags_workshops VALUES($1,$2,$3)`, id, workshop, id.String()); err != nil { + t.Fatal(err) + } + got, err := r.RecordAttendance(ctx, workshop, id) + if err != nil || !got.AlreadyRecorded { + t.Fatalf("got=%+v err=%v", got, err) + } + }) + for _, status := range []string{"accepted", "waitlist_confirmed", "rejected", "started"} { + t.Run("reject "+status, func(t *testing.T) { + id := addUser(status, true, false) + _, err := r.RecordAttendance(ctx, workshop, id) + if !errors.Is(err, ErrWorkshopAttendanceIneligible) { + t.Fatal(err) + } + }) + } + t.Run("reject not checked in", func(t *testing.T) { + id := addUser("confirmed", false, false) + _, err := r.RecordAttendance(ctx, workshop, id) + if !errors.Is(err, ErrWorkshopAttendanceIneligible) { + t.Fatal(err) + } + }) + t.Run("missing workshop", func(t *testing.T) { + _, err := r.RecordAttendance(ctx, uuid.New(), uuid.New()) + if !errors.Is(err, ErrWorkshopAttendanceNotFound) { + t.Fatal(err) + } + }) + t.Run("concurrent scans", func(t *testing.T) { + id := addUser("confirmed", true, false) + var wg sync.WaitGroup + errs := make(chan error, 8) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); _, err := r.RecordAttendance(ctx, workshop, id); errs <- err }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM nfc_tags_workshops WHERE user_id=$1 AND workshop_id=$2`, id, workshop).Scan(&count); err != nil || count != 1 { + t.Fatalf("count=%d err=%v", count, err) + } + }) + var registrations int + if err := pool.QueryRow(ctx, `SELECT num_attendees FROM workshops WHERE id=$1`, workshop).Scan(®istrations); err != nil || registrations != 0 { + t.Fatalf("RSVP count changed: %d err=%v", registrations, err) + } +} diff --git a/apps/api/internal/domains/workshops/attendance.go b/apps/api/internal/domains/workshops/attendance.go new file mode 100644 index 00000000..e3423799 --- /dev/null +++ b/apps/api/internal/domains/workshops/attendance.go @@ -0,0 +1,63 @@ +package workshops + +import ( + "context" + "errors" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" + "github.com/swamphacks/core/apps/api/internal/api/cookie" + "github.com/swamphacks/core/apps/api/internal/api/middleware" + "github.com/swamphacks/core/apps/api/internal/database/repository" +) + +func registerAttendanceRoutes(h *handler, group huma.API, mw *middleware.Middleware) { + huma.Register(group, huma.Operation{ + OperationID: "list-attendance-workshops", Method: http.MethodGet, Path: "/attendance-options", Summary: "List workshops and socials for staff scanning", Tags: []string{"Workshops"}, + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireStaffHuma}, Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, Errors: []int{401, 403, 500}, + }, h.handleAttendanceOptions) + huma.Register(group, huma.Operation{ + OperationID: "record-workshop-attendance", Method: http.MethodPost, Path: "/{workshopId}/attendance", Summary: "Record workshop attendance by hacker QR", Tags: []string{"Workshops"}, + Middlewares: huma.Middlewares{mw.Auth.RequireAuthHuma, mw.Auth.RequireStaffHuma}, Parameters: []*huma.Param{cookie.SessionCookieHumaParam}, Errors: []int{401, 403, 404, 409, 500}, + }, h.handleAttendance) +} + +type AttendanceOptionsOutput struct { + Body []repository.AttendanceWorkshop +} + +func (h *handler) handleAttendanceOptions(ctx context.Context, _ *struct{}) (*AttendanceOptionsOutput, error) { + rows, err := h.workshopService.workshopsRepo.AttendanceWorkshops(ctx) + if err != nil { + h.logger.Err(err).Msg("Unable to list attendance workshops") + return nil, huma.Error500InternalServerError("Unable to load workshops") + } + return &AttendanceOptionsOutput{Body: rows}, nil +} + +type AttendanceInput struct { + WorkshopID uuid.UUID `path:"workshopId"` + Body struct { + UserID uuid.UUID `json:"userId" required:"true"` + } +} +type AttendanceOutput struct { + Body *repository.WorkshopAttendance +} + +func (h *handler) handleAttendance(ctx context.Context, input *AttendanceInput) (*AttendanceOutput, error) { + row, err := h.workshopService.workshopsRepo.RecordAttendance(ctx, input.WorkshopID, input.Body.UserID) + if errors.Is(err, repository.ErrWorkshopAttendanceNotFound) { + return nil, huma.Error404NotFound("Workshop or hacker application not found") + } + if errors.Is(err, repository.ErrWorkshopAttendanceIneligible) { + return nil, huma.Error409Conflict("Hacker must be confirmed and checked in first") + } + if err != nil { + h.logger.Err(err).Msg("Unable to record workshop attendance") + return nil, huma.Error500InternalServerError("Unable to record attendance") + } + h.logger.Info().Str("user_id", input.Body.UserID.String()).Str("workshop_id", input.WorkshopID.String()).Bool("already_recorded", row.AlreadyRecorded).Msg("Workshop QR attendance") + return &AttendanceOutput{Body: row}, nil +} diff --git a/apps/api/internal/domains/workshops/http.go b/apps/api/internal/domains/workshops/http.go index 170c3910..941d29ff 100644 --- a/apps/api/internal/domains/workshops/http.go +++ b/apps/api/internal/domains/workshops/http.go @@ -19,6 +19,7 @@ import ( func RegisterRoutes(workshopHandler *handler, group huma.API, mw *middleware.Middleware) { + registerAttendanceRoutes(workshopHandler, group, mw) huma.Register(group, huma.Operation{ OperationID: "register-for-workshop", Method: http.MethodPost, diff --git a/apps/web/src/components/AppShell/AdminNavbar.tsx b/apps/web/src/components/AppShell/AdminNavbar.tsx index 1af6a3eb..efc939e7 100644 --- a/apps/web/src/components/AppShell/AdminNavbar.tsx +++ b/apps/web/src/components/AppShell/AdminNavbar.tsx @@ -20,6 +20,11 @@ export default function AdminNavbaProps({ pathname }: AdminNavbaPropsProps) { const commonNavLinks = ( <> + + + } + active={pathname.startsWith("/redeemables")} + /> (); + return { ok: true as const, value }; + } catch (error) { + let message = + "Unable to record attendance. Check the connection and scan again."; + if (error instanceof HTTPError) { + const body = await error.response + .json<{ detail?: string }>() + .catch(() => null); + message = body?.detail ?? message; + } + return { ok: false as const, message }; + } +} + +type ScanOutcome = + | { kind: "recorded"; attendance: Attendance } + | { kind: "error"; message: string }; +type ScanResult = ScanOutcome & { id: number }; + +// The camera reads a code about twice a second while it is in view. A code is +// only acted on again once it has been out of view this long, so a hacker +// holding up their phone does not repeatedly submit the same attendance. +const SAME_CODE_COOLDOWN_MS = 2000; +const HISTORY_LENGTH = 5; +// How long the green flash holds before it fades out. +const FLASH_HOLD_MS = 250; + +const fieldClass = + "rounded-lg border border-border bg-background px-3 py-2 text-sm"; +const actionClass = `${fieldClass} whitespace-nowrap font-medium`; + +// Select the session once, then scan attendees continuously. The server prevents duplicate attendance across QR and NFC. +export default function QrWorkshopScanner({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const [workshopId, setWorkshopId] = useState(""); + const [history, setHistory] = useState([]); + const [busy, setBusy] = useState(false); + const [cameraError, setCameraError] = useState(""); + const [flashing, setFlashing] = useState(false); + const flashTimer = useRef>(undefined); + // Refs, not state: the camera can report again before React re-renders, and + // these must already reflect the previous report when it does. + const inFlight = useRef(false); + const lastSeen = useRef(new Map()); + const nextId = useRef(0); + const workshops = useQuery({ + queryKey: ["workshop-attendance-options"], + queryFn: () => api.get("workshops/attendance-options").json(), + enabled: isOpen, + }); + const selected = workshops.data?.find((r) => r.id === workshopId); + + useEffect(() => () => clearTimeout(flashTimer.current), []); + + // Covers the camera in green so staff can see from arm's length that the + // scan went through and they shouldn't scan again. Phones that support it + // also buzz. + function flashSuccess() { + clearTimeout(flashTimer.current); + setFlashing(true); + flashTimer.current = setTimeout(() => setFlashing(false), FLASH_HOLD_MS); + navigator.vibrate?.(120); + } + + function reset() { + setHistory([]); + lastSeen.current.clear(); + } + + function close() { + reset(); + setCameraError(""); + onClose(); + } + + function record(outcome: ScanOutcome) { + const entry = { ...outcome, id: nextId.current++ }; + setHistory((h) => [entry, ...h].slice(0, HISTORY_LENGTH)); + } + + async function onScan(codes: IDetectedBarcode[]) { + const raw = codes[0]?.rawValue; + if (!raw || !workshopId || inFlight.current) return; + + const now = Date.now(); + const previous = lastSeen.current.get(raw); + lastSeen.current.set(raw, now); + if (previous !== undefined && now - previous < SAME_CODE_COOLDOWN_MS) { + return; + } + + const parsed = parseQrIntent(raw); + if (!parsed.ok) { + record({ + kind: "error", + message: "This is not a SwampHacks hacker QR code.", + }); + return; + } + + inFlight.current = true; + setBusy(true); + const response = await recordAttendance(workshopId, parsed.value.user_id); + record( + response.ok + ? { kind: "recorded", attendance: response.value } + : { kind: "error", message: response.message }, + ); + // Avoid submitting a code again while it stays in view. + lastSeen.current.set(raw, Date.now()); + inFlight.current = false; + setBusy(false); + if (response.ok) { + flashSuccess(); + } + } + + return ( + { + if (!open) close(); + }} + > +
+ + + {workshops.isError && ( +

+ Unable to load workshops: {workshops.error.message} +

+ )} + {workshops.data?.length === 0 && ( +

+ No workshops or socials are available. +

+ )} + + {selected && ( + <> +
+ + setCameraError( + "Camera unavailable. Allow camera access for this site and make sure no other app is using it.", + ) + } + paused={!isOpen} + // Report every read, not only new codes; onScan decides what + // counts as a fresh scan. + allowMultiple + constraints={{ facingMode: "environment" }} + formats={["qr_code"]} + sound={false} + /> + {/* Appears instantly, then fades out once flashing ends. */} +
+ +
+
+

+ {busy + ? "Recording attendance…" + : `Scanning for ${selected.title}. Hold up each hacker's QR code.`} +

+ {cameraError && ( +

+ {cameraError} +

+ )} + + )} + + {history.length > 0 && ( +
    + {history.map((entry, i) => ( +
  • + {entry.kind === "recorded" ? ( + <> +

    + {entry.attendance.workshopTitle} for{" "} + {entry.attendance.name} +

    + {i === 0 && ( +

    + {entry.attendance.alreadyRecorded + ? "Already recorded — no duplicate added." + : "Attendance recorded."} +

    + )} + + ) : ( +

    {entry.message}

    + )} +
  • + ))} +
+ )} + + +
+
+ ); +} diff --git a/apps/web/src/modules/Workshops/WorkshopAttendancePage.tsx b/apps/web/src/modules/Workshops/WorkshopAttendancePage.tsx new file mode 100644 index 00000000..0c0bdbfa --- /dev/null +++ b/apps/web/src/modules/Workshops/WorkshopAttendancePage.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import QrWorkshopScanner from "./QrWorkshopScanner"; +import { useWorkshopAttendance } from "./useWorkshopAttendance"; + +export default function WorkshopAttendancePage() { + const [open, setOpen] = useState(false); + const attendance = useWorkshopAttendance(); + + return ( +
+
+
+

Workshop & social attendance

+

+ Unique QR and NFC check-ins · refreshes every 10 seconds +

+
+ +
+ + {attendance.isPending &&

Loading attendance…

} + {attendance.isError && ( +
+

Unable to load attendance.

+ +
+ )} + {attendance.data?.length === 0 && ( +

+ No workshops or socials are available. +

+ )} + +
+ {attendance.data?.map((event) => ( +
+

+ {event.type === "social" ? "Social" : "Workshop"} +

+

{event.title}

+

+ {new Date(event.startTime).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })} +

+
+

+ {event.checkedInCount} +

+

Checked in

+
+
+ ))} +
+ + setOpen(false)} /> +
+ ); +} diff --git a/apps/web/src/modules/Workshops/test/scanner.test.tsx b/apps/web/src/modules/Workshops/test/scanner.test.tsx new file mode 100644 index 00000000..5bda2217 --- /dev/null +++ b/apps/web/src/modules/Workshops/test/scanner.test.tsx @@ -0,0 +1,105 @@ +import { + act, + fireEvent, + render, + screen, + cleanup, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import QrWorkshopScanner from "../QrWorkshopScanner"; + +const mocks = vi.hoisted(() => ({ + post: vi.fn(), + scan: undefined as + | undefined + | ((codes: { rawValue: string }[]) => Promise), +})); +vi.mock("@/lib/ky", () => ({ api: { post: mocks.post } })); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: [ + { + id: "session-1", + title: "Build a gator", + type: "workshop", + startTime: "2026-10-17T14:00:00Z", + }, + ], + isError: false, + }), +})); +vi.mock("@/components/ui/Modal", () => ({ + Modal: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@yudiel/react-qr-scanner", () => ({ + Scanner: ({ onScan }: { onScan: typeof mocks.scan }) => { + mocks.scan = onScan; + return
Camera
; + }, +})); +vi.mock("~icons/tabler/circle-check-filled", () => ({ default: () => null })); + +beforeEach(() => { + mocks.post.mockReset(); + mocks.scan = undefined; +}); +afterEach(cleanup); + +function selectSession() { + render( {}} />); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "session-1" }, + }); +} +const userId = "6603e995-7ac7-4af9-903b-ea5d4b0559b2"; + +describe("Workshop QR scanning", () => { + it("records the selected workshop and suppresses repeated camera reads", async () => { + mocks.post.mockReturnValue({ + json: async () => ({ + name: "Luca", + workshopTitle: "Build a gator", + alreadyRecorded: false, + }), + }); + selectSession(); + await act(async () => { + await mocks.scan?.([{ rawValue: `IDENT::${userId}` }]); + }); + expect(mocks.post).toHaveBeenCalledWith("workshops/session-1/attendance", { + json: { userId }, + retry: 0, + }); + expect(screen.getByText("Attendance recorded.")).toBeDefined(); + await act(async () => { + await mocks.scan?.([{ rawValue: `IDENT::${userId}` }]); + }); + expect(mocks.post).toHaveBeenCalledTimes(1); + }); + it("shows an existing NFC/QR record without claiming another attendance", async () => { + mocks.post.mockReturnValue({ + json: async () => ({ + name: "Luca", + workshopTitle: "Build a gator", + alreadyRecorded: true, + }), + }); + selectSession(); + await act(async () => { + await mocks.scan?.([{ rawValue: `IDENT::${userId}` }]); + }); + expect( + screen.getByText("Already recorded — no duplicate added."), + ).toBeDefined(); + }); + it("rejects non-hacker codes without an API write", async () => { + selectSession(); + await act(async () => { + await mocks.scan?.([{ rawValue: "https://example.com" }]); + }); + expect(mocks.post).not.toHaveBeenCalled(); + expect( + screen.getByText("This is not a SwampHacks hacker QR code."), + ).toBeDefined(); + }); +}); diff --git a/apps/web/src/modules/Workshops/useWorkshopAttendance.ts b/apps/web/src/modules/Workshops/useWorkshopAttendance.ts new file mode 100644 index 00000000..ffbf973f --- /dev/null +++ b/apps/web/src/modules/Workshops/useWorkshopAttendance.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/ky"; + +type AttendanceWorkshop = { + id: string; + title: string; + type: string; + startTime: string; + checkedInCount: number; +}; + +export function useWorkshopAttendance() { + return useQuery({ + queryKey: ["workshop-attendance-options"], + queryFn: () => + api.get("workshops/attendance-options").json(), + refetchInterval: 10000, + }); +} diff --git a/apps/web/src/routes/_protected/_admin/redeemables.tsx b/apps/web/src/routes/_protected/_staff/redeemables.tsx similarity index 78% rename from apps/web/src/routes/_protected/_admin/redeemables.tsx rename to apps/web/src/routes/_protected/_staff/redeemables.tsx index 34fd8481..c36e2316 100644 --- a/apps/web/src/routes/_protected/_admin/redeemables.tsx +++ b/apps/web/src/routes/_protected/_staff/redeemables.tsx @@ -1,7 +1,7 @@ import RedeemablesPage from "@/modules/Redeemables/RedeemablesPage"; import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_protected/_admin/redeemables")({ +export const Route = createFileRoute("/_protected/_staff/redeemables")({ component: RouteComponent, }); diff --git a/apps/web/src/routes/_protected/_staff/workshop-attendance.tsx b/apps/web/src/routes/_protected/_staff/workshop-attendance.tsx new file mode 100644 index 00000000..cbf46ffa --- /dev/null +++ b/apps/web/src/routes/_protected/_staff/workshop-attendance.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import WorkshopAttendancePage from "@/modules/Workshops/WorkshopAttendancePage"; + +export const Route = createFileRoute("/_protected/_staff/workshop-attendance")({ + component: WorkshopAttendancePage, +});