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.`}
+