diff --git a/Dockerfile b/Dockerfile index 81b87bd3..37795660 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build frontend -FROM node:22-alpine AS frontend +FROM node:22.23.1-alpine AS frontend WORKDIR /app/client/web COPY client/web/package.json client/web/package-lock.json ./ @@ -13,7 +13,7 @@ ENV VITE_GOOGLE_AUTH_ENABLED=$VITE_GOOGLE_AUTH_ENABLED RUN npm run build # Stage 2: Build backend -FROM golang:1.24 AS builder +FROM golang:1.24.13 AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/claude.md b/claude.md index 187ce3b4..205eefac 100644 --- a/claude.md +++ b/claude.md @@ -165,5 +165,5 @@ Runs on every push/PR to `main` (`.github/workflows/audit.yaml`): **Auth:** `GET /v1/auth/check-email`, `GET /v1/auth/me` **Hacker:** `GET|PATCH /v1/applications/me`, `POST /v1/applications/me/submit` **Admin:** `GET /v1/admin/applications`, `GET /v1/admin/applications/stats`, `GET /v1/admin/applications/{id}`, `GET /v1/admin/applications/{id}/notes`, `GET /v1/admin/reviews/pending`, `GET /v1/admin/reviews/completed`, `GET /v1/admin/reviews/next`, `PUT /v1/admin/reviews/{id}`, `GET /v1/admin/scans/types`, `POST /v1/admin/scans`, `GET /v1/admin/scans/user/{userID}`, `GET /v1/admin/scans/stats`, `POST /v1/admin/scans/rebalance-stats` -**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr` +**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr`, `GET /v1/superadmin/walk-ins`, `POST /v1/superadmin/walk-ins/promote` **Infra (Basic Auth):** `GET /v1/health`, `GET /v1/debug/vars`, `GET /v1/swagger/*` diff --git a/client/web/dev.Dockerfile b/client/web/dev.Dockerfile index eadde422..a9915d5a 100644 --- a/client/web/dev.Dockerfile +++ b/client/web/dev.Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM node:22.23.1-alpine WORKDIR /app COPY package.json package-lock.json ./ diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 8cb025fe..e1c8922e 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -113,7 +113,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -4895,7 +4894,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4906,7 +4904,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4917,7 +4914,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4981,7 +4977,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5207,7 +5202,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5480,7 +5474,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6074,8 +6067,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -6318,7 +6310,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8647,7 +8638,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8678,7 +8668,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8691,7 +8680,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9573,7 +9561,6 @@ "resolved": "https://registry.npmjs.org/supertokens-web-js/-/supertokens-web-js-0.16.0.tgz", "integrity": "sha512-wuIdlVJtOsx4ZX0kCyl8lxmmAodXLlMAniZEHyVhsH2fhersh7bMrHpvgN9WoC470HYNC22qpHdlJngvyh/cSA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@simplewebauthn/browser": "^13.0.0", "supertokens-js-override": "0.0.4", @@ -9680,7 +9667,6 @@ "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "devOptional": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9738,7 +9724,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9908,7 +9893,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10192,7 +10176,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10315,7 +10298,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10557,7 +10539,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10765,7 +10746,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx index 60412b39..b98f4e4b 100644 --- a/client/web/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx @@ -4,6 +4,7 @@ import { Bell, Calendar, ClipboardList, + DoorOpen, Handshake, ScanLine, Settings, @@ -81,6 +82,11 @@ const superAdminNav = [ url: "/admin/sa/notifications", icon: Bell, }, + { + name: "Walk-In Queue", + url: "/admin/sa/walk-in-queue", + icon: DoorOpen, + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx index f3a7b31b..37bf6747 100644 --- a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx +++ b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx @@ -1,4 +1,10 @@ -import { Gift, MoreHorizontal, UserCheck, Utensils } from "lucide-react"; +import { + DoorOpen, + Gift, + MoreHorizontal, + UserCheck, + Utensils, +} from "lucide-react"; import { Card, @@ -14,6 +20,7 @@ const categoryIcons: Record = { meal: Utensils, swag: Gift, other: MoreHorizontal, + walk_in: DoorOpen, }; interface ScanStatsCardsProps { diff --git a/client/web/src/pages/admin/scans/types.ts b/client/web/src/pages/admin/scans/types.ts index 2bc59e5e..bac249e5 100644 --- a/client/web/src/pages/admin/scans/types.ts +++ b/client/web/src/pages/admin/scans/types.ts @@ -1,4 +1,9 @@ -export type ScanTypeCategory = "check_in" | "meal" | "swag" | "other"; +export type ScanTypeCategory = + | "check_in" + | "meal" + | "swag" + | "other" + | "walk_in"; export interface ScanType { name: string; diff --git a/client/web/src/pages/admin/scans/utils.ts b/client/web/src/pages/admin/scans/utils.ts index eabf5564..108879c6 100644 --- a/client/web/src/pages/admin/scans/utils.ts +++ b/client/web/src/pages/admin/scans/utils.ts @@ -1,4 +1,10 @@ -import { Gift, MoreHorizontal, UserCheck, Utensils } from "lucide-react"; +import { + DoorOpen, + Gift, + MoreHorizontal, + UserCheck, + Utensils, +} from "lucide-react"; import type { ScanType, ScanTypeCategory } from "./types"; @@ -18,9 +24,17 @@ export function validate(types: ScanType[]): string | null { if (new Set(names).size !== names.length) { return "Scan type names must be unique"; } - const checkInCount = types.filter((st) => st.category === "check_in").length; - if (checkInCount !== 1) { - return "Exactly one scan type must have the check_in category"; + const hasCheckIn = types.some( + (st) => st.is_active && st.category === "check_in", + ); + if (!hasCheckIn) { + return "At least one active check_in scan type is required"; + } + const hasWalkIn = types.some( + (st) => st.is_active && st.category === "walk_in", + ); + if (!hasWalkIn) { + return "At least one active walk_in scan type is required"; } return null; } @@ -30,6 +44,7 @@ export const categoryIcons: Record = { meal: Utensils, swag: Gift, other: MoreHorizontal, + walk_in: DoorOpen, }; export const categoryColors: Record = { @@ -37,6 +52,7 @@ export const categoryColors: Record = { meal: "bg-orange-100 text-orange-800", swag: "bg-purple-100 text-purple-800", other: "bg-gray-100 text-gray-800", + walk_in: "bg-violet-100 text-violet-700", }; export const categoryOptions = [ @@ -44,4 +60,5 @@ export const categoryOptions = [ { value: "meal", label: "Meal" }, { value: "swag", label: "Swag" }, { value: "other", label: "Other" }, + { value: "walk_in", label: "Walk-In" }, ] as const; diff --git a/client/web/src/pages/superadmin/walk-in-queue/WalkInQueuePage.tsx b/client/web/src/pages/superadmin/walk-in-queue/WalkInQueuePage.tsx new file mode 100644 index 00000000..31ab1f35 --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/WalkInQueuePage.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +import { getWalkInQueue } from "./api"; +import { PromoteDialog } from "./components/PromoteDialog"; +import { WalkInQueueTable } from "./components/WalkInQueueTable"; +import type { WalkInsResponse } from "./types"; + +const REFRESH_INTERVAL_MS = 30_000; + +export default function WalkInQueuePage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const abortRef = useRef(null); + + // Used by PromoteDialog's onSuccess — never called directly in an effect body. + const refresh = useCallback(() => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + void getWalkInQueue(controller.signal).then((res) => { + if (res.status === 0) return; + setData(res.data ?? null); + }); + }, []); + + useEffect(() => { + const controller = new AbortController(); + abortRef.current = controller; + + void getWalkInQueue(controller.signal).then((res) => { + if (res.status === 0) return; + setData(res.data ?? null); + setLoading(false); + }); + + const interval = setInterval(refresh, REFRESH_INTERVAL_MS); + return () => { + clearInterval(interval); + controller.abort(); + }; + }, [refresh]); + + const pending = data?.pending ?? 0; + const total = data?.total ?? 0; + + return ( +
+
+
+

Walk-In Queue

+ {!loading && ( +

+ {pending} waiting · {total - pending} promoted ·{" "} + {total} total walk-ins +

+ )} +
+ +
+ + + + Pending Queue + + Walk-ins are listed in arrival order. Position 1 is next to be + promoted. + + + + {loading && !data ? ( +
+ Loading... +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/client/web/src/pages/superadmin/walk-in-queue/api.ts b/client/web/src/pages/superadmin/walk-in-queue/api.ts new file mode 100644 index 00000000..b737d4eb --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/api.ts @@ -0,0 +1,24 @@ +import { getRequest, postRequest } from "@/shared/lib/api"; +import type { ApiResponse } from "@/types"; + +import type { PromoteResponse, WalkInsResponse } from "./types"; + +export function getWalkInQueue( + signal?: AbortSignal, +): Promise> { + return getRequest( + "/superadmin/walk-ins", + "fetch walk-in queue", + signal, + ); +} + +export function promoteWalkIns( + count: number, +): Promise> { + return postRequest( + "/superadmin/walk-ins/promote", + { count }, + "promote walk-ins", + ); +} diff --git a/client/web/src/pages/superadmin/walk-in-queue/components/PromoteDialog.tsx b/client/web/src/pages/superadmin/walk-in-queue/components/PromoteDialog.tsx new file mode 100644 index 00000000..37bf85ef --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/components/PromoteDialog.tsx @@ -0,0 +1,115 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { promoteWalkIns } from "../api"; + +const schema = z.object({ + count: z.number().int().min(1, "Must promote at least 1"), +}); + +type FormValues = z.infer; + +interface PromoteDialogProps { + pending: number; + onSuccess: () => void; +} + +export function PromoteDialog({ pending, onSuccess }: PromoteDialogProps) { + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { count: Math.min(pending, 20) || 1 }, + }); + + async function onSubmit(values: FormValues) { + const res = await promoteWalkIns(values.count); + if (res.error || !res.data) { + toast.error(res.error ?? "Failed to promote walk-ins"); + return; + } + toast.success( + `Promoted ${res.data.promoted_count} walk-in${res.data.promoted_count !== 1 ? "s" : ""} and sent acceptance emails`, + ); + setOpen(false); + form.reset({ count: 1 }); + onSuccess(); + } + + return ( + { + if (!next) form.reset({ count: Math.min(pending, 20) || 1 }); + setOpen(next); + }} + > + + + + + + Promote walk-ins + + Promote the next N people in the queue to accepted and send them + acceptance emails with their QR codes. + + +
+ + ( + + Number to promote + + field.onChange(e.target.valueAsNumber)} + /> + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/client/web/src/pages/superadmin/walk-in-queue/components/WalkInQueueTable.tsx b/client/web/src/pages/superadmin/walk-in-queue/components/WalkInQueueTable.tsx new file mode 100644 index 00000000..c5d09f7e --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/components/WalkInQueueTable.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import type { WalkIn } from "../types"; + +const PAGE_SIZE = 20; + +interface WalkInQueueTableProps { + queue: WalkIn[]; +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleString(); +} + +export function WalkInQueueTable({ queue }: WalkInQueueTableProps) { + const [page, setPage] = useState(0); + + if (queue.length === 0) { + return ( +
+ No walk-ins in queue. +
+ ); + } + + const totalPages = Math.ceil(queue.length / PAGE_SIZE); + // Clamp in case the queue shrank (auto-refresh) while on a later page. + const safePage = Math.min(page, totalPages - 1); + const start = safePage * PAGE_SIZE; + const paginated = queue.slice(start, start + PAGE_SIZE); + + return ( +
+
+ + + + + # + + + Email + + Arrived + + + + {paginated.map((entry) => ( + + + {entry.position} + + + {entry.email} + + + {formatTime(entry.queued_at)} + + + ))} + +
+
+ +
+ + {start + 1}–{Math.min(start + PAGE_SIZE, queue.length)} of{" "} + {queue.length} + +
+ + +
+
+
+ ); +} diff --git a/client/web/src/pages/superadmin/walk-in-queue/index.ts b/client/web/src/pages/superadmin/walk-in-queue/index.ts new file mode 100644 index 00000000..0ed546f4 --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/index.ts @@ -0,0 +1 @@ +export { default } from "./WalkInQueuePage"; diff --git a/client/web/src/pages/superadmin/walk-in-queue/types.ts b/client/web/src/pages/superadmin/walk-in-queue/types.ts new file mode 100644 index 00000000..bdeb6fd4 --- /dev/null +++ b/client/web/src/pages/superadmin/walk-in-queue/types.ts @@ -0,0 +1,18 @@ +export interface WalkIn { + id: string; + user_id: string; + email: string; + queued_at: string; + position: number; +} + +export interface WalkInsResponse { + pending: number; + total: number; + queue: WalkIn[]; +} + +export interface PromoteResponse { + promoted_count: number; + promoted: { id: string; email: string }[]; +} diff --git a/client/web/src/routes.tsx b/client/web/src/routes.tsx index 1eadd511..f6114b61 100644 --- a/client/web/src/routes.tsx +++ b/client/web/src/routes.tsx @@ -43,6 +43,9 @@ const SuperAdminScansPage = lazy( const SuperAdminNotificationsPage = lazy( () => import("@/pages/superadmin/notifications/NotificationsPage"), ); +const SuperAdminWalkInQueuePage = lazy( + () => import("@/pages/superadmin/walk-in-queue/WalkInQueuePage"), +); const AdminGradingPage = lazy( () => import("@/pages/admin/reviews/grading/GradingPage"), ); @@ -227,6 +230,16 @@ export const router = createBrowserRouter([ ), }, + { + path: "sa/walk-in-queue", + element: ( + + }> + + + + ), + }, ], }, { diff --git a/cmd/api/api.go b/cmd/api/api.go index 534a118d..d3813eab 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -273,6 +273,11 @@ func (app *application) mount() http.Handler { r.Put("/applications-enabled", app.setApplicationsEnabled) }) + r.Route("/walk-ins", func(r chi.Router) { + r.Get("/", app.getWalkInsHandler) + r.Post("/promote", app.promoteWalkInsHandler) + }) + r.Route("/applications", func(r chi.Router) { r.Post("/assign", app.batchAssignReviews) r.Get("/emails", app.getApplicantEmailsByStatusHandler) diff --git a/cmd/api/main.go b/cmd/api/main.go index 089c6194..5273c10a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -67,8 +67,9 @@ func main() { Username: env.GetString("EMAIL_USERNAME", ""), Password: env.GetString("EMAIL_PASSWORD", ""), }, - FromEmail: env.GetString("EMAIL_FROM", "noreply@example.com"), - FromName: env.GetString("EMAIL_FROM_NAME", mailer.FromName), + FromEmail: env.GetString("EMAIL_FROM", "noreply@example.com"), + FromName: env.GetString("EMAIL_FROM_NAME", env.GetString("HACKATHON_NAME", mailer.DefaultHackathonName)), + HackathonName: env.GetString("HACKATHON_NAME", mailer.DefaultHackathonName), }, gcs: gcsConfig{ bucketName: env.GetString("GCS_BUCKET_NAME", ""), diff --git a/cmd/api/scans.go b/cmd/api/scans.go index c8766d5d..3aa0a1c6 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "math/rand" "net/http" @@ -114,7 +115,32 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request return } - if found.Category != store.ScanCategoryCheckIn { + // Walk-in scan: skip check-in prerequisite, enqueue user, send queued email. + if found.Category == store.ScanCategoryWalkIn { + scannedUser, err := app.store.Users.GetByID(r.Context(), req.UserID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + app.notFoundResponse(w, r, errors.New("user not found")) + return + } + app.internalServerError(w, r, err) + return + } + + inserted, position, err := app.store.WalkIns.Enqueue(r.Context(), req.UserID) + if err != nil { + app.internalServerError(w, r, err) + return + } + if inserted { + go func() { + if err := app.mailer.SendWalkInQueuedEmail(scannedUser.Email, position); err != nil { + app.logger.Errorw("failed to send walk-in queued email", "error", err) + } + }() + } + } else if found.Category != store.ScanCategoryCheckIn { + // Non-walk-in, non-check-in scans require the user to have checked in first. var checkInTypes []string for _, st := range scanTypes { if st.Category == store.ScanCategoryCheckIn { @@ -132,6 +158,21 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request app.forbiddenResponse(w, r, errors.New("user must check in before claiming items")) return } + } else { + // Check-in scan: require accepted status. + status, err := app.store.Application.GetStatusByUserID(r.Context(), req.UserID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + app.forbiddenResponse(w, r, errors.New("user has no application")) + return + } + app.internalServerError(w, r, err) + return + } + if status != store.StatusAccepted { + app.forbiddenResponse(w, r, fmt.Errorf("user is not accepted (status: %s)", status)) + return + } } admin := getUserFromContext(r.Context()) @@ -296,7 +337,7 @@ func (app *application) rebalanceScanStatsHandler(w http.ResponseWriter, r *http // updateScanTypesHandler replaces all scan types with the provided array // // @Summary Update scan types (Super Admin) -// @Description Replaces all scan types with the provided array. Must include at least one check_in category type. Names must be unique. +// @Description Replaces all scan types with the provided array. Must include at least one active check_in category type and at least one active walk_in category type. Names must be unique. // @Tags superadmin/settings // @Accept json // @Produce json @@ -330,15 +371,23 @@ func (app *application) updateScanTypesHandler(w http.ResponseWriter, r *http.Re nameMap[st.Name] = true } - // Validate exactly one check_in category type exists - checkInCount := 0 + // Validate at least one active check_in and one active walk_in type exist. + hasCheckIn := false + hasWalkIn := false for _, st := range req.ScanTypes { - if st.Category == store.ScanCategoryCheckIn { - checkInCount++ + if st.IsActive && st.Category == store.ScanCategoryCheckIn { + hasCheckIn = true + } + if st.IsActive && st.Category == store.ScanCategoryWalkIn { + hasWalkIn = true } } - if checkInCount != 1 { - app.badRequestResponse(w, r, errors.New("exactly one scan type must have the check_in category")) + if !hasCheckIn { + app.badRequestResponse(w, r, errors.New("at least one active check_in scan type is required")) + return + } + if !hasWalkIn { + app.badRequestResponse(w, r, errors.New("at least one active walk_in scan type is required")) return } diff --git a/cmd/api/scans_test.go b/cmd/api/scans_test.go index 2e0104b8..7e617b9a 100644 --- a/cmd/api/scans_test.go +++ b/cmd/api/scans_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/go-chi/chi" + "github.com/hackutd/portal/internal/mailer" "github.com/hackutd/portal/internal/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -63,6 +64,7 @@ func TestCreateScan(t *testing.T) { hackerApp := &store.Application{ID: "app-1", UserID: "user-1", MealGroup: nil} mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockApps.On("GetStatusByUserID", "user-1").Return(store.StatusAccepted, nil).Once() mockSettings.On("GetMealGroups").Return(groups, nil).Once() mockApps.On("GetByUserID", "user-1").Return(hackerApp, nil).Once() mockApps.On("SetMealGroup", "app-1", mock.AnythingOfType("string")). @@ -102,6 +104,7 @@ func TestCreateScan(t *testing.T) { hackerApp := &store.Application{ID: "app-1", UserID: "user-1", MealGroup: &existing} mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockApps.On("GetStatusByUserID", "user-1").Return(store.StatusAccepted, nil).Once() mockSettings.On("GetMealGroups").Return(groups, nil).Once() mockApps.On("GetByUserID", "user-1").Return(hackerApp, nil).Once() mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() @@ -137,6 +140,7 @@ func TestCreateScan(t *testing.T) { mockApps := app.store.Application.(*store.MockApplicationStore) mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockApps.On("GetStatusByUserID", "user-1").Return(store.StatusAccepted, nil).Once() mockSettings.On("GetMealGroups").Return(nil, errors.New("db error")).Once() mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() @@ -213,8 +217,10 @@ func TestCreateScan(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) mockScans := app.store.Scans.(*store.MockScansStore) + mockApp := app.store.Application.(*store.MockApplicationStore) mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockApp.On("GetStatusByUserID", "user-1").Return(store.StatusAccepted, nil).Once() mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(store.ErrConflict).Once() body := `{"user_id":"user-1","scan_type":"check_in"}` @@ -228,6 +234,7 @@ func TestCreateScan(t *testing.T) { mockSettings.AssertExpectations(t) mockScans.AssertExpectations(t) + mockApp.AssertExpectations(t) }) t.Run("400 invalid type", func(t *testing.T) { @@ -278,6 +285,143 @@ func TestCreateScan(t *testing.T) { rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) checkResponseCode(t, http.StatusBadRequest, rr.Code) }) + + walkInScanTypes := []store.ScanType{ + {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true}, + {Name: "walk_in", DisplayName: "Walk-In", Category: store.ScanCategoryWalkIn, IsActive: true}, + {Name: "lunch", DisplayName: "Lunch", Category: store.ScanCategoryMeal, IsActive: true}, + } + + t.Run("walk-in scan enqueues user and fires queued email", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockUsers := app.store.Users.(*store.MockUsersStore) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + mockMailer := app.mailer.(*mailer.MockClient) + + mockApps := app.store.Application.(*store.MockApplicationStore) + + mockSettings.On("GetScanTypes").Return(walkInScanTypes, nil).Once() + mockUsers.On("GetByID", "user-1").Return(&store.User{ID: "user-1", Email: "hacker@test.com"}, nil).Once() + mockWalkIns.On("Enqueue", "user-1").Return(true, 7, nil).Once() + mockMailer.On("SendWalkInQueuedEmail", "hacker@test.com", 7).Return(nil).Maybe() + mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return((*string)(nil), store.ErrNotFound).Once() + + body := `{"user_id":"user-1","scan_type":"walk_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + mockUsers.AssertExpectations(t) + mockWalkIns.AssertExpectations(t) + }) + + t.Run("walk-in re-scan is no-op, no second email", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockUsers := app.store.Users.(*store.MockUsersStore) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + mockMailer := app.mailer.(*mailer.MockClient) + + mockApps := app.store.Application.(*store.MockApplicationStore) + + mockSettings.On("GetScanTypes").Return(walkInScanTypes, nil).Once() + mockUsers.On("GetByID", "user-1").Return(&store.User{ID: "user-1", Email: "hacker@test.com"}, nil).Once() + mockWalkIns.On("Enqueue", "user-1").Return(false, 0, nil).Once() + mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return((*string)(nil), store.ErrNotFound).Once() + + body := `{"user_id":"user-1","scan_type":"walk_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + mockMailer.AssertNotCalled(t, "SendWalkInQueuedEmail", mock.Anything, mock.Anything) + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + mockWalkIns.AssertExpectations(t) + }) + + t.Run("walk-in scan does not require prior check-in", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockUsers := app.store.Users.(*store.MockUsersStore) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + + mockApps := app.store.Application.(*store.MockApplicationStore) + + mockSettings.On("GetScanTypes").Return(walkInScanTypes, nil).Once() + mockUsers.On("GetByID", "user-1").Return(&store.User{ID: "user-1", Email: "hacker@test.com"}, nil).Once() + mockWalkIns.On("Enqueue", "user-1").Return(false, 0, nil).Once() + mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return((*string)(nil), store.ErrNotFound).Once() + + body := `{"user_id":"user-1","scan_type":"walk_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + // Should succeed without any HasCheckIn mock — walk-in bypasses that check + checkResponseCode(t, http.StatusCreated, rr.Code) + }) + + t.Run("check-in scan of waitlisted user returns 403", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockApp := app.store.Application.(*store.MockApplicationStore) + + mockSettings.On("GetScanTypes").Return(walkInScanTypes, nil).Once() + mockApp.On("GetStatusByUserID", "user-1").Return(store.StatusWaitlisted, nil).Once() + + body := `{"user_id":"user-1","scan_type":"check_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusForbidden, rr.Code) + + mockSettings.AssertExpectations(t) + mockApp.AssertExpectations(t) + }) + + t.Run("check-in scan of user with no application returns 403", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockApp := app.store.Application.(*store.MockApplicationStore) + + mockSettings.On("GetScanTypes").Return(walkInScanTypes, nil).Once() + mockApp.On("GetStatusByUserID", "user-1").Return(store.ApplicationStatus(""), store.ErrNotFound).Once() + + body := `{"user_id":"user-1","scan_type":"check_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusForbidden, rr.Code) + + mockSettings.AssertExpectations(t) + mockApp.AssertExpectations(t) + }) } func TestGetUserScans(t *testing.T) { @@ -457,12 +601,13 @@ func TestUpdateScanTypes(t *testing.T) { types := []store.ScanType{ {Name: "check_in", DisplayName: "Check In", Category: store.ScanCategoryCheckIn, IsActive: true}, + {Name: "walk_in", DisplayName: "Walk-In", Category: store.ScanCategoryWalkIn, IsActive: true}, {Name: "lunch", DisplayName: "Lunch", Category: store.ScanCategoryMeal, IsActive: true}, } mockSettings.On("UpdateScanTypes", types).Return(nil).Once() - body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true},{"name":"lunch","display_name":"Lunch","category":"meal","is_active":true}]}` + body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true},{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true},{"name":"lunch","display_name":"Lunch","category":"meal","is_active":true}]}` req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -497,7 +642,27 @@ func TestUpdateScanTypes(t *testing.T) { t.Run("400 no check_in category", func(t *testing.T) { app := newTestApplication(t) - body := `{"scan_types":[{"name":"lunch","display_name":"Lunch","category":"meal","is_active":true}]}` + body := `{"scan_types":[{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true}]}` + req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.updateScanTypesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + var errBody struct { + Error string `json:"error"` + } + err = json.NewDecoder(rr.Body).Decode(&errBody) + require.NoError(t, err) + assert.Contains(t, errBody.Error, "check_in") + }) + + t.Run("400 no walk_in category", func(t *testing.T) { + app := newTestApplication(t) + + body := `{"scan_types":[{"name":"check_in","display_name":"Check In","category":"check_in","is_active":true}]}` req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") @@ -511,7 +676,7 @@ func TestUpdateScanTypes(t *testing.T) { } err = json.NewDecoder(rr.Body).Decode(&errBody) require.NoError(t, err) - assert.Contains(t, errBody.Error, "check_in category") + assert.Contains(t, errBody.Error, "walk_in") }) t.Run("400 empty body", func(t *testing.T) { diff --git a/cmd/api/walkins.go b/cmd/api/walkins.go new file mode 100644 index 00000000..2bfcb51e --- /dev/null +++ b/cmd/api/walkins.go @@ -0,0 +1,112 @@ +package main + +import ( + "errors" + "net/http" + + "github.com/hackutd/portal/internal/store" +) + +type WalkInsResponse struct { + Pending int `json:"pending"` + Total int `json:"total"` + Queue []store.WalkIn `json:"queue"` +} + +type PromoteWalkInsPayload struct { + Count int `json:"count" validate:"required,min=1"` +} + +type PromoteWalkInsResponse struct { + PromotedCount int `json:"promoted_count"` + Promoted []store.User `json:"promoted"` +} + +// getWalkInsHandler returns the current walk-in queue depth and pending entries +// +// @Summary Get walk-in queue (Super Admin) +// @Description Returns pending count, total count, and ordered list of un-promoted walk-in entries +// @Tags superadmin/walk-ins +// @Produce json +// @Success 200 {object} WalkInsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/walk-ins [get] +func (app *application) getWalkInsHandler(w http.ResponseWriter, r *http.Request) { + pending, total, err := app.store.WalkIns.QueueDepth(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + queue, err := app.store.WalkIns.List(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, WalkInsResponse{ + Pending: pending, + Total: total, + Queue: queue, + }); err != nil { + app.internalServerError(w, r, err) + } +} + +// promoteWalkInsHandler promotes the next N walk-ins and sends acceptance emails +// +// @Summary Promote walk-ins (Super Admin) +// @Description Promotes the next N un-promoted walk-ins in FIFO order and sends acceptance emails +// @Tags superadmin/walk-ins +// @Accept json +// @Produce json +// @Param body body PromoteWalkInsPayload true "Number of walk-ins to promote" +// @Success 200 {object} PromoteWalkInsResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/walk-ins/promote [post] +func (app *application) promoteWalkInsHandler(w http.ResponseWriter, r *http.Request) { + var req PromoteWalkInsPayload + if err := readJSON(w, r, &req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := Validate.Struct(req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + admin := getUserFromContext(r.Context()) + if admin == nil { + app.unauthorizedErrorResponse(w, r, errors.New("user not in context")) + return + } + + promoted, err := app.store.WalkIns.PromoteNext(r.Context(), req.Count, admin.ID) + if err != nil { + app.internalServerError(w, r, err) + return + } + + for _, u := range promoted { + go func() { + if err := app.mailer.SendWalkInAcceptedEmail(u.Email, u.ID); err != nil { + app.logger.Errorw("failed to send walk-in accepted email", "error", err, "user_id", u.ID) + } + }() + } + + if err := app.jsonResponse(w, http.StatusOK, PromoteWalkInsResponse{ + PromotedCount: len(promoted), + Promoted: promoted, + }); err != nil { + app.internalServerError(w, r, err) + } +} diff --git a/cmd/api/walkins_test.go b/cmd/api/walkins_test.go new file mode 100644 index 00000000..2799d17f --- /dev/null +++ b/cmd/api/walkins_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/hackutd/portal/internal/mailer" + "github.com/hackutd/portal/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetWalkIns(t *testing.T) { + t.Run("returns queue depth and pending list", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + + queue := []store.WalkIn{ + {ID: "wi-1", UserID: "user-1", Email: "a@test.com", Position: 1}, + {ID: "wi-2", UserID: "user-2", Email: "b@test.com", Position: 2}, + {ID: "wi-3", UserID: "user-3", Email: "c@test.com", Position: 3}, + } + mockWalkIns.On("QueueDepth").Return(3, 5, nil).Once() + mockWalkIns.On("List").Return(queue, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getWalkInsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data WalkInsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, 3, body.Data.Pending) + assert.Equal(t, 5, body.Data.Total) + assert.Len(t, body.Data.Queue, 3) + assert.Equal(t, 1, body.Data.Queue[0].Position) + + mockWalkIns.AssertExpectations(t) + }) + + t.Run("empty queue returns zero counts", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + + mockWalkIns.On("QueueDepth").Return(0, 0, nil).Once() + mockWalkIns.On("List").Return([]store.WalkIn{}, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getWalkInsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data WalkInsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, 0, body.Data.Pending) + assert.Empty(t, body.Data.Queue) + + mockWalkIns.AssertExpectations(t) + }) + + t.Run("store error returns 500", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + + mockWalkIns.On("QueueDepth").Return(0, 0, errors.New("db error")).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getWalkInsHandler)) + checkResponseCode(t, http.StatusInternalServerError, rr.Code) + + mockWalkIns.AssertExpectations(t) + }) +} + +func TestPromoteWalkIns(t *testing.T) { + t.Run("promotes N users and fires acceptance emails", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + mockMailer := app.mailer.(*mailer.MockClient) + + promoted := []store.User{ + {ID: "user-1", Email: "a@test.com"}, + {ID: "user-2", Email: "b@test.com"}, + {ID: "user-3", Email: "c@test.com"}, + } + mockWalkIns.On("PromoteNext", 3, "superadmin-1").Return(promoted, nil).Once() + mockMailer.On("SendWalkInAcceptedEmail", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe() + + body := `{"count":3}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.promoteWalkInsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var resp struct { + Data PromoteWalkInsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, 3, resp.Data.PromotedCount) + assert.Len(t, resp.Data.Promoted, 3) + + mockWalkIns.AssertExpectations(t) + }) + + t.Run("count=0 returns 400", func(t *testing.T) { + app := newTestApplication(t) + + body := `{"count":0}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.promoteWalkInsHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("promotes only available when count exceeds pending", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + mockMailer := app.mailer.(*mailer.MockClient) + + promoted := []store.User{ + {ID: "user-1", Email: "a@test.com"}, + } + mockWalkIns.On("PromoteNext", 5, "superadmin-1").Return(promoted, nil).Once() + mockMailer.On("SendWalkInAcceptedEmail", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe() + + body := `{"count":5}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.promoteWalkInsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var resp struct { + Data PromoteWalkInsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, 1, resp.Data.PromotedCount) + + mockWalkIns.AssertExpectations(t) + }) + + t.Run("store error returns 500", func(t *testing.T) { + app := newTestApplication(t) + mockWalkIns := app.store.WalkIns.(*store.MockWalkInsStore) + + mockWalkIns.On("PromoteNext", 3, "superadmin-1").Return(nil, errors.New("db error")).Once() + + body := `{"count":3}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.promoteWalkInsHandler)) + checkResponseCode(t, http.StatusInternalServerError, rr.Code) + + mockWalkIns.AssertExpectations(t) + }) +} diff --git a/cmd/migrate/migrations/000020_add_walk_ins.down.sql b/cmd/migrate/migrations/000020_add_walk_ins.down.sql new file mode 100644 index 00000000..1ca9d3d3 --- /dev/null +++ b/cmd/migrate/migrations/000020_add_walk_ins.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS walk_ins; diff --git a/cmd/migrate/migrations/000020_add_walk_ins.up.sql b/cmd/migrate/migrations/000020_add_walk_ins.up.sql new file mode 100644 index 00000000..4042591d --- /dev/null +++ b/cmd/migrate/migrations/000020_add_walk_ins.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE walk_ins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + promoted_at TIMESTAMPTZ, + promoted_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- FIFO index: un-promoted rows first (promoted_at NULLS FIRST), then arrival order +CREATE INDEX idx_walk_ins_fifo ON walk_ins (promoted_at NULLS FIRST, queued_at ASC); diff --git a/cmd/migrate/migrations/000021_add_walkin_scan_type.down.sql b/cmd/migrate/migrations/000021_add_walkin_scan_type.down.sql new file mode 100644 index 00000000..bd243cb6 --- /dev/null +++ b/cmd/migrate/migrations/000021_add_walkin_scan_type.down.sql @@ -0,0 +1,10 @@ +UPDATE settings +SET value = COALESCE( + ( + SELECT jsonb_agg(elem) + FROM jsonb_array_elements(value) AS elem + WHERE elem->>'name' != 'walk_in' + ), + '[]'::jsonb +) +WHERE key = 'scan_types'; diff --git a/cmd/migrate/migrations/000021_add_walkin_scan_type.up.sql b/cmd/migrate/migrations/000021_add_walkin_scan_type.up.sql new file mode 100644 index 00000000..e0bfb6a4 --- /dev/null +++ b/cmd/migrate/migrations/000021_add_walkin_scan_type.up.sql @@ -0,0 +1,4 @@ +UPDATE settings +SET value = value || '[{"name":"walk_in","display_name":"Walk-In","category":"walk_in","is_active":true}]'::jsonb +WHERE key = 'scan_types' + AND NOT (value @> '[{"name":"walk_in"}]'::jsonb); diff --git a/dev.Dockerfile b/dev.Dockerfile index 9c35bca2..3cf6714a 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -1,10 +1,10 @@ -FROM golang:1.24.11 +FROM golang:1.24.13 WORKDIR /app # Install dev tools: air (hot reload), swag (swagger), task (taskfile runner) RUN go install github.com/air-verse/air@v1.61.5 && \ - go install github.com/swaggo/swag/cmd/swag@latest && \ - go install github.com/go-task/task/v3/cmd/task@latest + go install github.com/swaggo/swag/cmd/swag@v1.16.6 && \ + go install github.com/go-task/task/v3/cmd/task@v3.40.1 # Install golang-migrate RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.18.1/migrate.linux-amd64.tar.gz | tar xvz && \ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 287d9880..b99f2b29 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -30,13 +30,15 @@ services: - DB_ADDR=postgres://admin:adminpassword@db:5432/portal?sslmode=disable - SUPERTOKENS_CONNECTION_URI=${SUPERTOKENS_CONNECTION_URI} - SUPERTOKENS_API_KEY=${SUPERTOKENS_API_KEY} - - AUTH_BASIC_USER=admin - - AUTH_BASIC_PASS=admin + - AUTH_BASIC_USER=${AUTH_BASIC_USER} + - AUTH_BASIC_PASS=${AUTH_BASIC_PASS} - APP_URL=http://localhost:8080 - FRONTEND_URL=http://localhost:3000 - ENV=development - - GOOGLE_CLIENT_ID=your-google-client-id - - GOOGLE_CLIENT_SECRET=your-google-client-secret + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - PUBLIC_API_KEY=${PUBLIC_API_KEY} + - SENDGRID_API_KEY=${SENDGRID_API_KEY} ports: - "8080:8080" volumes: diff --git a/docker-compose.local-st.yml b/docker-compose.local-st.yml index 85e71c64..ce8a559a 100644 --- a/docker-compose.local-st.yml +++ b/docker-compose.local-st.yml @@ -19,7 +19,7 @@ services: retries: 5 supertokens: - image: registry.supertokens.io/supertokens/supertokens-postgresql + image: registry.supertokens.io/supertokens/supertokens-postgresql:11.4.5 container_name: harp-dev-supertokens depends_on: db: diff --git a/docs/docs.go b/docs/docs.go index 39612254..cdd6cf79 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4537,7 +4537,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Replaces all scan types with the provided array. Must include at least one check_in category type. Names must be unique.", + "description": "Replaces all scan types with the provided array. Must include at least one active check_in category type and at least one active walk_in category type. Names must be unique.", "consumes": [ "application/json" ], @@ -4803,6 +4803,147 @@ const docTemplate = `{ } } } + }, + "/superadmin/walk-ins": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns pending count, total count, and ordered list of un-promoted walk-in entries", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/walk-ins" + ], + "summary": "Get walk-in queue (Super Admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.WalkInsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/superadmin/walk-ins/promote": { + "post": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Promotes the next N un-promoted walk-ins in FIFO order and sends acceptance emails", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/walk-ins" + ], + "summary": "Promote walk-ins (Super Admin)", + "parameters": [ + { + "description": "Number of walk-ins to promote", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.PromoteWalkInsPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.PromoteWalkInsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } } }, "definitions": { @@ -5135,6 +5276,32 @@ const docTemplate = `{ } } }, + "main.PromoteWalkInsPayload": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 1 + } + } + }, + "main.PromoteWalkInsResponse": { + "type": "object", + "properties": { + "promoted": { + "type": "array", + "items": { + "$ref": "#/definitions/store.User" + } + }, + "promoted_count": { + "type": "integer" + } + } + }, "main.ResetHackathonPayload": { "type": "object", "properties": { @@ -5658,6 +5825,23 @@ const docTemplate = `{ } } }, + "main.WalkInsResponse": { + "type": "object", + "properties": { + "pending": { + "type": "integer" + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/definitions/store.WalkIn" + } + }, + "total": { + "type": "integer" + } + } + }, "store.Application": { "type": "object", "properties": { @@ -6078,7 +6262,8 @@ const docTemplate = `{ "check_in", "meal", "swag", - "other" + "other", + "walk_in" ], "allOf": [ { @@ -6107,13 +6292,15 @@ const docTemplate = `{ "check_in", "meal", "swag", - "other" + "other", + "walk_in" ], "x-enum-varnames": [ "ScanCategoryCheckIn", "ScanCategoryMeal", "ScanCategorySwag", - "ScanCategoryOther" + "ScanCategoryOther", + "ScanCategoryWalkIn" ] }, "store.ScheduleItem": { @@ -6238,6 +6425,58 @@ const docTemplate = `{ } } }, + "store.User": { + "type": "object", + "required": [ + "auth_method", + "email", + "role", + "supertokens_user_id" + ], + "properties": { + "auth_method": { + "enum": [ + "passwordless", + "google" + ], + "allOf": [ + { + "$ref": "#/definitions/store.AuthMethod" + } + ] + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "role": { + "enum": [ + "hacker", + "admin", + "super_admin" + ], + "allOf": [ + { + "$ref": "#/definitions/store.UserRole" + } + ] + }, + "supertokens_user_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "store.UserListItem": { "type": "object", "properties": { @@ -6276,6 +6515,32 @@ const docTemplate = `{ "RoleAdmin", "RoleSuperAdmin" ] + }, + "store.WalkIn": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "position": { + "type": "integer" + }, + "promoted_at": { + "type": "string" + }, + "promoted_by": { + "type": "string" + }, + "queued_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/go.mod b/go.mod index acff74f2..f810b3c7 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/supertokens/supertokens-golang v0.25.1 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 + github.com/wneessen/go-mail v0.7.2 go.uber.org/zap v1.27.1 ) @@ -103,7 +104,7 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect gopkg.in/h2non/gock.v1 v1.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2eb2d0e3..33e6db9f 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/twilio/twilio-go v0.26.0 h1:wFW4oTe3/LKt6bvByP7eio8JsjtaLHjMQKOUEzQry7U= github.com/twilio/twilio-go v0.26.0/go.mod h1:lz62Hopu4vicpQ056H5TJ0JE4AP0rS3sQ35/ejmgOwE= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 04da9718..fb90e18b 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -6,7 +6,9 @@ import ( ) const ( - FromName = "HackUTD" + // DefaultHackathonName is the fallback event name used in email subjects + // and bodies when HACKATHON_NAME is unset. + DefaultHackathonName = "Hackathon" ) //go:embed template/* @@ -14,13 +16,16 @@ var FS embed.FS type Client interface { SendQREmail(toEmail, toName, userID string) error + SendWalkInQueuedEmail(toEmail string, position int) error + SendWalkInAcceptedEmail(toEmail, userID string) error } type Config struct { - FromEmail string - FromName string - SendGrid SendGridConfig - SMTP SMTPConfig + FromEmail string + FromName string + HackathonName string + SendGrid SendGridConfig + SMTP SMTPConfig } type SendGridConfig struct { @@ -49,9 +54,10 @@ func New(cfg Config) (Client, error) { cfg.SMTP.Password, cfg.FromEmail, cfg.FromName, - ), nil + cfg.HackathonName, + ) case cfg.SendGrid.APIKey != "": - return NewSendGrid(cfg.SendGrid.APIKey, cfg.FromEmail, cfg.FromName), nil + return NewSendGrid(cfg.SendGrid.APIKey, cfg.FromEmail, cfg.FromName, cfg.HackathonName), nil default: return nil, fmt.Errorf("no mailer configured: set SMTP (EMAIL_HOST, EMAIL_USERNAME, EMAIL_PASSWORD) or SENDGRID_API_KEY") } diff --git a/internal/mailer/mock_mailer.go b/internal/mailer/mock_mailer.go index b68f07cf..73e8f774 100644 --- a/internal/mailer/mock_mailer.go +++ b/internal/mailer/mock_mailer.go @@ -10,3 +10,13 @@ func (m *MockClient) SendQREmail(toEmail, toName, userID string) error { args := m.Called(toEmail, toName, userID) return args.Error(0) } + +func (m *MockClient) SendWalkInQueuedEmail(toEmail string, position int) error { + args := m.Called(toEmail, position) + return args.Error(0) +} + +func (m *MockClient) SendWalkInAcceptedEmail(toEmail, userID string) error { + args := m.Called(toEmail, userID) + return args.Error(0) +} diff --git a/internal/mailer/sendgrid.go b/internal/mailer/sendgrid.go index 7db8bb79..bd9a7a37 100644 --- a/internal/mailer/sendgrid.go +++ b/internal/mailer/sendgrid.go @@ -11,23 +11,39 @@ import ( qrcode "github.com/skip2/go-qrcode" ) +type walkInQueuedData struct { + Email string + Position int + HackathonName string +} + +type walkInAcceptedData struct { + Email string + HackathonName string +} + type SendGridMailer struct { - fromEmail string - fromName string - client *sendgrid.Client + fromEmail string + fromName string + hackathonName string + client *sendgrid.Client } -func NewSendGrid(apiKey, fromEmail, fromName string) *SendGridMailer { +func NewSendGrid(apiKey, fromEmail, fromName, hackathonName string) *SendGridMailer { client := sendgrid.NewSendClient(apiKey) + if hackathonName == "" { + hackathonName = DefaultHackathonName + } if fromName == "" { - fromName = FromName + fromName = hackathonName } return &SendGridMailer{ - fromEmail: fromEmail, - fromName: fromName, - client: client, + fromEmail: fromEmail, + fromName: fromName, + hackathonName: hackathonName, + client: client, } } @@ -50,7 +66,7 @@ func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error { } var htmlBody bytes.Buffer - err = tmpl.Execute(&htmlBody, map[string]string{"Name": toName}) + err = tmpl.Execute(&htmlBody, map[string]string{"Name": toName, "HackathonName": m.hackathonName}) if err != nil { return fmt.Errorf("executing email template: %w", err) } @@ -60,7 +76,7 @@ func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error { message := mail.NewV3Mail() message.SetFrom(from) - message.Subject = "Your HackUTD QR Code" + message.Subject = fmt.Sprintf("Your %s QR Code", m.hackathonName) p := mail.NewPersonalization() p.AddTos(to) @@ -85,3 +101,94 @@ func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error { return nil } + +func (m *SendGridMailer) SendWalkInQueuedEmail(toEmail string, position int) error { + tmplData, err := FS.ReadFile("template/walk_in_queued.html") + if err != nil { + return fmt.Errorf("reading walk_in_queued template: %w", err) + } + + tmpl, err := template.New("walk_in_queued").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing walk_in_queued template: %w", err) + } + + var htmlBody bytes.Buffer + if err := tmpl.Execute(&htmlBody, walkInQueuedData{Email: toEmail, Position: position, HackathonName: m.hackathonName}); err != nil { + return fmt.Errorf("executing walk_in_queued template: %w", err) + } + + from := mail.NewEmail(m.fromName, m.fromEmail) + to := mail.NewEmail(toEmail, toEmail) + + message := mail.NewV3Mail() + message.SetFrom(from) + message.Subject = fmt.Sprintf("You're #%d in the %s walk-in queue", position, m.hackathonName) + + p := mail.NewPersonalization() + p.AddTos(to) + message.AddPersonalizations(p) + message.AddContent(mail.NewContent("text/html", htmlBody.String())) + + response, err := m.client.Send(message) + if err != nil { + return fmt.Errorf("sending walk-in queued email: %w", err) + } + if response.StatusCode >= 400 { + return fmt.Errorf("sendgrid returned status %d: %s", response.StatusCode, response.Body) + } + + return nil +} + +func (m *SendGridMailer) SendWalkInAcceptedEmail(toEmail, userID string) error { + qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256) + if err != nil { + return fmt.Errorf("generating QR code: %w", err) + } + qrBase64 := base64.StdEncoding.EncodeToString(qrPNG) + + tmplData, err := FS.ReadFile("template/walk_in_accepted.html") + if err != nil { + return fmt.Errorf("reading walk_in_accepted template: %w", err) + } + + tmpl, err := template.New("walk_in_accepted").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing walk_in_accepted template: %w", err) + } + + var htmlBody bytes.Buffer + if err := tmpl.Execute(&htmlBody, walkInAcceptedData{Email: toEmail, HackathonName: m.hackathonName}); err != nil { + return fmt.Errorf("executing walk_in_accepted template: %w", err) + } + + from := mail.NewEmail(m.fromName, m.fromEmail) + to := mail.NewEmail(toEmail, toEmail) + + message := mail.NewV3Mail() + message.SetFrom(from) + message.Subject = fmt.Sprintf("You're in — %s Walk-In Acceptance", m.hackathonName) + + p := mail.NewPersonalization() + p.AddTos(to) + message.AddPersonalizations(p) + message.AddContent(mail.NewContent("text/html", htmlBody.String())) + + attachment := mail.NewAttachment() + attachment.SetContent(qrBase64) + attachment.SetType("image/png") + attachment.SetFilename("hackutd-qrcode.png") + attachment.SetDisposition("attachment") + message.AddAttachment(attachment) + + response, err := m.client.Send(message) + if err != nil { + return fmt.Errorf("sending walk-in accepted email: %w", err) + } + if response.StatusCode >= 400 { + return fmt.Errorf("sendgrid returned status %d: %s", response.StatusCode, response.Body) + } + + return nil +} diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go index 2c04e610..662ff1a8 100644 --- a/internal/mailer/smtp.go +++ b/internal/mailer/smtp.go @@ -4,40 +4,51 @@ import ( "bytes" "fmt" "html/template" - "io" qrcode "github.com/skip2/go-qrcode" - gomail "gopkg.in/gomail.v2" + mail "github.com/wneessen/go-mail" ) type SMTPMailer struct { - host string - port int - username string - password string - fromEmail string - fromName string + fromEmail string + fromName string + hackathonName string + client *mail.Client } -func NewSMTP(host string, port int, username, password, fromEmail, fromName string) *SMTPMailer { +func NewSMTP(host string, port int, username, password, fromEmail, fromName, hackathonName string) (*SMTPMailer, error) { if port == 0 { port = 587 } if fromEmail == "" { fromEmail = username } + if hackathonName == "" { + hackathonName = DefaultHackathonName + } if fromName == "" { - fromName = FromName + fromName = hackathonName } - return &SMTPMailer{ - host: host, - port: port, - username: username, - password: password, - fromEmail: fromEmail, - fromName: fromName, + // TLSMandatory covers real providers: STARTTLS on 587, implicit TLS on 465. + // A plaintext local catcher (e.g. Mailpit) would need TLSOpportunistic instead. + client, err := mail.NewClient(host, + mail.WithPort(port), + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), + mail.WithUsername(username), + mail.WithPassword(password), + mail.WithTLSPortPolicy(mail.TLSMandatory), + ) + if err != nil { + return nil, fmt.Errorf("creating SMTP client: %w", err) } + + return &SMTPMailer{ + fromEmail: fromEmail, + fromName: fromName, + hackathonName: hackathonName, + client: client, + }, nil } func (m *SMTPMailer) SendQREmail(toEmail, toName, userID string) error { @@ -57,28 +68,100 @@ func (m *SMTPMailer) SendQREmail(toEmail, toName, userID string) error { } var htmlBody bytes.Buffer - if err := tmpl.Execute(&htmlBody, map[string]string{"Name": toName}); err != nil { + if err := tmpl.Execute(&htmlBody, map[string]string{"Name": toName, "HackathonName": m.hackathonName}); err != nil { return fmt.Errorf("executing email template: %w", err) } - msg := gomail.NewMessage() - msg.SetAddressHeader("From", m.fromEmail, m.fromName) - msg.SetAddressHeader("To", toEmail, toName) - msg.SetHeader("Subject", "Your HackUTD QR Code") - msg.SetBody("text/html", htmlBody.String()) - msg.Attach( - "hackutd-qrcode.png", - gomail.SetCopyFunc(func(w io.Writer) error { - _, err := w.Write(qrPNG) - return err - }), - gomail.SetHeader(map[string][]string{"Content-Type": {"image/png"}}), - ) + msg := mail.NewMsg() + if err := msg.FromFormat(m.fromName, m.fromEmail); err != nil { + return fmt.Errorf("setting from address: %w", err) + } + if err := msg.AddToFormat(toName, toEmail); err != nil { + return fmt.Errorf("setting to address: %w", err) + } + msg.Subject(fmt.Sprintf("Your %s QR Code", m.hackathonName)) + msg.SetBodyString(mail.TypeTextHTML, htmlBody.String()) + if err := msg.AttachReader("hackutd-qrcode.png", bytes.NewReader(qrPNG), mail.WithFileContentType("image/png")); err != nil { + return fmt.Errorf("attaching QR code: %w", err) + } - dialer := gomail.NewDialer(m.host, m.port, m.username, m.password) - if err := dialer.DialAndSend(msg); err != nil { + if err := m.client.DialAndSend(msg); err != nil { return fmt.Errorf("sending email: %w", err) } return nil } + +func (m *SMTPMailer) SendWalkInQueuedEmail(toEmail string, position int) error { + tmplData, err := FS.ReadFile("template/walk_in_queued.html") + if err != nil { + return fmt.Errorf("reading walk_in_queued template: %w", err) + } + + tmpl, err := template.New("walk_in_queued").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing walk_in_queued template: %w", err) + } + + var htmlBody bytes.Buffer + if err := tmpl.Execute(&htmlBody, walkInQueuedData{Email: toEmail, Position: position, HackathonName: m.hackathonName}); err != nil { + return fmt.Errorf("executing walk_in_queued template: %w", err) + } + + msg := mail.NewMsg() + if err := msg.FromFormat(m.fromName, m.fromEmail); err != nil { + return fmt.Errorf("setting from address: %w", err) + } + if err := msg.AddToFormat(toEmail, toEmail); err != nil { + return fmt.Errorf("setting to address: %w", err) + } + msg.Subject(fmt.Sprintf("You're #%d in the %s walk-in queue", position, m.hackathonName)) + msg.SetBodyString(mail.TypeTextHTML, htmlBody.String()) + + if err := m.client.DialAndSend(msg); err != nil { + return fmt.Errorf("sending walk-in queued email: %w", err) + } + + return nil +} + +func (m *SMTPMailer) SendWalkInAcceptedEmail(toEmail, userID string) error { + qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256) + if err != nil { + return fmt.Errorf("generating QR code: %w", err) + } + + tmplData, err := FS.ReadFile("template/walk_in_accepted.html") + if err != nil { + return fmt.Errorf("reading walk_in_accepted template: %w", err) + } + + tmpl, err := template.New("walk_in_accepted").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing walk_in_accepted template: %w", err) + } + + var htmlBody bytes.Buffer + if err := tmpl.Execute(&htmlBody, walkInAcceptedData{Email: toEmail, HackathonName: m.hackathonName}); err != nil { + return fmt.Errorf("executing walk_in_accepted template: %w", err) + } + + msg := mail.NewMsg() + if err := msg.FromFormat(m.fromName, m.fromEmail); err != nil { + return fmt.Errorf("setting from address: %w", err) + } + if err := msg.AddToFormat(toEmail, toEmail); err != nil { + return fmt.Errorf("setting to address: %w", err) + } + msg.Subject(fmt.Sprintf("You're in — %s Walk-In Acceptance", m.hackathonName)) + msg.SetBodyString(mail.TypeTextHTML, htmlBody.String()) + if err := msg.AttachReader("hackutd-qrcode.png", bytes.NewReader(qrPNG), mail.WithFileContentType("image/png")); err != nil { + return fmt.Errorf("attaching QR code: %w", err) + } + + if err := m.client.DialAndSend(msg); err != nil { + return fmt.Errorf("sending walk-in accepted email: %w", err) + } + + return nil +} diff --git a/internal/mailer/template/qr_email.html b/internal/mailer/template/qr_email.html index 61381c4c..70278714 100644 --- a/internal/mailer/template/qr_email.html +++ b/internal/mailer/template/qr_email.html @@ -3,7 +3,7 @@ - Your HackUTD QR Code + Your {{.HackathonName}} QR Code

- HackUTD + {{.HackathonName}}

@@ -67,7 +67,7 @@

Hey {{.Name}},

" >

- © HackUTD. All rights reserved. + © {{.HackathonName}}. All rights reserved.

diff --git a/internal/mailer/template/walk_in_accepted.html b/internal/mailer/template/walk_in_accepted.html new file mode 100644 index 00000000..0150370f --- /dev/null +++ b/internal/mailer/template/walk_in_accepted.html @@ -0,0 +1,12 @@ + + + + +

You're In — {{.HackathonName}} Walk-In Acceptance

+

Hi {{.Email}},

+

Great news — you've been accepted as a walk-in to {{.HackathonName}}!

+

Your QR code is attached. Please be in line within 10 minutes of receiving this email or your spot may be given to the next person in the queue.

+

Show your QR code at the check-in desk to get in.

+

— The {{.HackathonName}} Team

+ + diff --git a/internal/mailer/template/walk_in_queued.html b/internal/mailer/template/walk_in_queued.html new file mode 100644 index 00000000..aa279e28 --- /dev/null +++ b/internal/mailer/template/walk_in_queued.html @@ -0,0 +1,12 @@ + + + + +

You're in the {{.HackathonName}} Walk-In Queue

+

Hi {{.Email}},

+

You've been added to the {{.HackathonName}} walk-in queue at position #{{.Position}}.

+

Your application status is now waitlisted. We'll email you with a QR code if a spot opens up.

+

Please make sure you are within a 10 minute distance of the check-in area so you can make it in time when called.

+

— The {{.HackathonName}} Team

+ + diff --git a/internal/store/applications.go b/internal/store/applications.go index 7ec3bfad..90483d9f 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -595,6 +595,22 @@ type UserEmailInfo struct { LastName *string `json:"last_name"` } +func (s *ApplicationsStore) GetStatusByUserID(ctx context.Context, userID string) (ApplicationStatus, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + var status ApplicationStatus + err := s.db.QueryRowContext(ctx, + `SELECT status FROM applications WHERE user_id = $1`, userID).Scan(&status) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + return status, nil +} + func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index e8cbe64c..8fd12a3d 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -138,6 +138,11 @@ func (m *MockApplicationStore) SetStatus(ctx context.Context, id string, status return args.Get(0).(*Application), args.Error(1) } +func (m *MockApplicationStore) GetStatusByUserID(ctx context.Context, userID string) (ApplicationStatus, error) { + args := m.Called(userID) + return args.Get(0).(ApplicationStatus), args.Error(1) +} + func (m *MockApplicationStore) GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) { args := m.Called(status) if args.Get(0) == nil { @@ -557,6 +562,37 @@ func (m *MockScheduledNotificationsStore) GenerateFromSchedule(ctx context.Conte return args.Get(0).(*ScheduleNotificationGenerationResult), args.Error(1) } +// MockWalkInsStore is a mock implementation of the WalkIns interface +type MockWalkInsStore struct { + mock.Mock +} + +func (m *MockWalkInsStore) Enqueue(ctx context.Context, userID string) (bool, int, error) { + args := m.Called(userID) + return args.Bool(0), args.Int(1), args.Error(2) +} + +func (m *MockWalkInsStore) PromoteNext(ctx context.Context, count int, promotedBy string) ([]User, error) { + args := m.Called(count, promotedBy) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]User), args.Error(1) +} + +func (m *MockWalkInsStore) QueueDepth(ctx context.Context) (int, int, error) { + args := m.Called() + return args.Int(0), args.Int(1), args.Error(2) +} + +func (m *MockWalkInsStore) List(ctx context.Context) ([]WalkIn, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]WalkIn), args.Error(1) +} + // returns a Storage with all mock implementations func NewMockStore() Storage { return Storage{ @@ -570,5 +606,6 @@ func NewMockStore() Storage { Sponsors: &MockSponsorsStore{}, PushSubscriptions: &MockPushSubscriptionsStore{}, ScheduledNotifications: &MockScheduledNotificationsStore{}, + WalkIns: &MockWalkInsStore{}, } } diff --git a/internal/store/scans.go b/internal/store/scans.go index b51f4698..479f46c2 100644 --- a/internal/store/scans.go +++ b/internal/store/scans.go @@ -18,12 +18,13 @@ const ( ScanCategoryMeal ScanTypeCategory = "meal" ScanCategorySwag ScanTypeCategory = "swag" ScanCategoryOther ScanTypeCategory = "other" + ScanCategoryWalkIn ScanTypeCategory = "walk_in" ) type ScanType struct { Name string `json:"name" validate:"required,min=1,max=50"` DisplayName string `json:"display_name" validate:"required,min=1,max=100"` - Category ScanTypeCategory `json:"category" validate:"required,oneof=check_in meal swag other"` + Category ScanTypeCategory `json:"category" validate:"required,oneof=check_in meal swag other walk_in"` IsActive bool `json:"is_active"` } diff --git a/internal/store/storage.go b/internal/store/storage.go index 8bbed1f1..a7cde65d 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -30,6 +30,7 @@ type Storage struct { Application interface { GetByUserID(ctx context.Context, userID string) (*Application, error) GetByID(ctx context.Context, id string) (*Application, error) + GetStatusByUserID(ctx context.Context, userID string) (ApplicationStatus, error) Create(ctx context.Context, app *Application) error Update(ctx context.Context, app *Application) error Submit(ctx context.Context, app *Application) error @@ -112,6 +113,12 @@ type Storage struct { MarkSent(ctx context.Context, id string, recipientCount int) error GenerateFromSchedule(ctx context.Context, lead time.Duration, targetRole *UserRole, createdBy string, now time.Time) (*ScheduleNotificationGenerationResult, error) } + WalkIns interface { + Enqueue(ctx context.Context, userID string) (inserted bool, position int, err error) + PromoteNext(ctx context.Context, count int, promotedBy string) ([]User, error) + QueueDepth(ctx context.Context) (pending int, total int, err error) + List(ctx context.Context) ([]WalkIn, error) + } } func NewStorage(db *sql.DB) Storage { @@ -126,5 +133,6 @@ func NewStorage(db *sql.DB) Storage { Sponsors: &SponsorsStore{db: db}, PushSubscriptions: &PushSubscriptionsStore{db: db}, ScheduledNotifications: &ScheduledNotificationsStore{db: db}, + WalkIns: &WalkInsStore{db: db}, } } diff --git a/internal/store/walkins.go b/internal/store/walkins.go new file mode 100644 index 00000000..7bbcf5ea --- /dev/null +++ b/internal/store/walkins.go @@ -0,0 +1,235 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type WalkIn struct { + ID string `json:"id"` + UserID string `json:"user_id"` + QueuedAt time.Time `json:"queued_at"` + PromotedAt *time.Time `json:"promoted_at"` + PromotedBy *string `json:"promoted_by"` + Email string `json:"email"` + Position int `json:"position"` +} + +type WalkInsStore struct { + db *sql.DB +} + +// Enqueue adds a user to the walk-in queue. +// Returns (inserted, position, err). +// inserted=true on first enqueue; false on re-scan or if user is already accepted. +// position is the 1-indexed FIFO position at enqueue time (only valid when inserted=true). +func (s *WalkInsStore) Enqueue(ctx context.Context, userID string) (bool, int, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, 0, err + } + defer tx.Rollback() + + // 1. Short-circuit: already accepted, do not re-enqueue. + var status sql.NullString + err = tx.QueryRowContext(ctx, + `SELECT status FROM applications WHERE user_id = $1`, userID).Scan(&status) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, 0, err + } + if status.Valid && status.String == string(StatusAccepted) { + return false, 0, tx.Commit() + } + + // 2. Insert walk_ins row; no-op on conflict (re-scan). + res, err := tx.ExecContext(ctx, + `INSERT INTO walk_ins (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, userID) + if err != nil { + return false, 0, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return false, 0, tx.Commit() + } + + // 3. Upsert application to waitlisted. + _, err = tx.ExecContext(ctx, ` + INSERT INTO applications (user_id, status, submitted_at, responses) + VALUES ($1, 'waitlisted', NOW(), '{}') + ON CONFLICT (user_id) DO UPDATE + SET status = 'waitlisted', + submitted_at = COALESCE(applications.submitted_at, EXCLUDED.submitted_at) + `, userID) + if err != nil { + return false, 0, err + } + + // 4. Compute FIFO position for the queued email. + var position int + err = tx.QueryRowContext(ctx, ` + SELECT pos FROM ( + SELECT user_id, + ROW_NUMBER() OVER (ORDER BY queued_at ASC, id ASC)::int AS pos + FROM walk_ins + WHERE promoted_at IS NULL + ) ranked + WHERE user_id = $1 + `, userID).Scan(&position) + if err != nil { + return false, 0, err + } + + return true, position, tx.Commit() +} + +// PromoteNext promotes the next count un-promoted walk-ins in FIFO order. +// Returns the promoted users (ID and email) so the caller can send emails. +func (s *WalkInsStore) PromoteNext(ctx context.Context, count int, promotedBy string) ([]User, error) { + ctx, cancel := context.WithTimeout(ctx, 2*QueryTimeoutDuration) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + // Select the next N un-promoted entries, SKIP LOCKED prevents double-promotion. + rows, err := tx.QueryContext(ctx, ` + SELECT w.id, w.user_id, u.email + FROM walk_ins w + JOIN users u ON u.id = w.user_id + WHERE w.promoted_at IS NULL + ORDER BY w.queued_at ASC, w.id ASC + LIMIT $1 + FOR UPDATE OF w SKIP LOCKED + `, count) + if err != nil { + return nil, err + } + + type selectedRow struct { + walkInID string + userID string + email string + } + var selected []selectedRow + for rows.Next() { + var r selectedRow + if err := rows.Scan(&r.walkInID, &r.userID, &r.email); err != nil { + rows.Close() + return nil, err + } + selected = append(selected, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + if len(selected) == 0 { + return []User{}, tx.Commit() + } + + walkInIDs := make([]string, len(selected)) + userIDs := make([]string, len(selected)) + for i, r := range selected { + walkInIDs[i] = r.walkInID + userIDs[i] = r.userID + } + + // Stamp promoted_at on all selected walk_ins rows. + _, err = tx.ExecContext(ctx, ` + UPDATE walk_ins + SET promoted_at = NOW(), promoted_by = $1 + WHERE id = ANY($2::uuid[]) + `, promotedBy, walkInIDs) + if err != nil { + return nil, err + } + + // Flip application status to accepted, creating rows for any user without one. + _, err = tx.ExecContext(ctx, ` + INSERT INTO applications (user_id, status, submitted_at, responses) + SELECT uid, 'accepted', NOW(), '{}' + FROM unnest($1::uuid[]) AS uid + ON CONFLICT (user_id) DO UPDATE + SET status = 'accepted', + submitted_at = COALESCE(applications.submitted_at, EXCLUDED.submitted_at) + `, userIDs) + if err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + promoted := make([]User, len(selected)) + for i, r := range selected { + promoted[i] = User{ID: r.userID, Email: r.email} + } + return promoted, nil +} + +// QueueDepth returns the pending (un-promoted) count and total count of walk-in entries. +func (s *WalkInsStore) QueueDepth(ctx context.Context) (int, int, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + var pending, total int + err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) FILTER (WHERE promoted_at IS NULL), + COUNT(*) + FROM walk_ins + `).Scan(&pending, &total) + if err != nil { + return 0, 0, err + } + return pending, total, nil +} + +// List returns all un-promoted walk-in entries ordered by arrival time, with live positions. +func (s *WalkInsStore) List(ctx context.Context) ([]WalkIn, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` + SELECT + w.id, w.user_id, w.queued_at, w.promoted_at, w.promoted_by, + u.email, + ROW_NUMBER() OVER (ORDER BY w.queued_at ASC, w.id ASC)::int AS position + FROM walk_ins w + JOIN users u ON u.id = w.user_id + WHERE w.promoted_at IS NULL + ORDER BY w.queued_at ASC, w.id ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []WalkIn + for rows.Next() { + var w WalkIn + if err := rows.Scan( + &w.ID, &w.UserID, &w.QueuedAt, &w.PromotedAt, &w.PromotedBy, + &w.Email, &w.Position, + ); err != nil { + return nil, err + } + result = append(result, w) + } + + if result == nil { + result = []WalkIn{} + } + + return result, rows.Err() +}