Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
90 changes: 90 additions & 0 deletions apps/api/internal/database/repository/workshop_attendance.go
Original file line number Diff line number Diff line change
@@ -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
}
150 changes: 150 additions & 0 deletions apps/api/internal/database/repository/workshop_attendance_test.go
Original file line number Diff line number Diff line change
@@ -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(&registrations); err != nil || registrations != 0 {
t.Fatalf("RSVP count changed: %d err=%v", registrations, err)
}
}
63 changes: 63 additions & 0 deletions apps/api/internal/domains/workshops/attendance.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions apps/api/internal/domains/workshops/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/AppShell/AdminNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export default function AdminNavbaProps({ pathname }: AdminNavbaPropsProps) {

const commonNavLinks = (
<>
<NavLink
label="Workshop attendance"
href="/workshop-attendance"
active={pathname.startsWith("/workshop-attendance")}
/>
<NavLink
label="Check-in Dashboard"
href="/check-in"
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/AppShell/StaffNavbar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import TablerGift from "~icons/tabler/gift";
import { NavLink } from "@/components/AppShell/NavLink";
import TablerInfoCircle from "~icons/tabler/info-circle";
import TablerClipboardCheck from "~icons/tabler/clipboard-check";
Expand All @@ -19,11 +20,22 @@ export default function StaffNavbar({ pathname }: StaffNavbarProps) {

const commonNavLinks = (
<>
<NavLink
label="Workshop attendance"
href="/workshop-attendance"
active={pathname.startsWith("/workshop-attendance")}
/>
<NavLink
label="Check-in Dashboard"
href="/check-in"
active={pathname.startsWith("/check-in")}
/>
<NavLink
label="Redeemables"
href="/redeemables"
leftSection={<TablerGift className="w-5 aspect-square" />}
active={pathname.startsWith("/redeemables")}
/>
<NavLink
label="Information"
href="/information"
Expand Down
Loading
Loading