From 5a048f50f722f1ee82a3003b09fdd1c677d035f3 Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sun, 13 Sep 2026 13:53:23 -0400 Subject: [PATCH 1/4] improved judging command center controls Co-authored-by: Codex --- .forge/features/hacker-management/spec.md | 34 +- .forge/features/hacker-management/srd.md | 53 +- .forge/features/hacker-management/status.md | 37 +- .../features/hacker-management/test-cases.md | 34 +- .../judging-command-center-reset/spec.md | 48 + .../judging-command-center-reset/srd.md | 62 + .../judging-command-center-reset/status.md | 43 + .../test-cases.md | 50 + .../hackathon/hackers/bulk-confirm-dialog.tsx | 188 ++- .../hackers/hacker-detail-dialog.tsx | 17 +- .../hackathon/hackers/hacker-filters.tsx | 21 +- .../admin/hackathon/hackers/hacker-roster.tsx | 66 +- .../admin/hackathon/hackers/hacker-table.tsx | 12 +- .../judging/judging-announcements.tsx | 16 +- .../judging/judging-configuration-panel.tsx | 195 +-- .../judging/judging-control-panel.tsx | 100 +- .../judging/judging-launch-checklist.tsx | 178 +++ .../judging/judging-reset-panel.tsx | 377 ++++++ .../judging/judging-schedule-panel.tsx | 120 +- .../judging/project-command-center.tsx | 158 ++- .../projects/admin-project-workspace.tsx | 168 ++- .../projects/project-directory.tsx | 8 + apps/blade/src/app/admin/judging/page.tsx | 18 +- .../tests/admin/bulk-confirm-dialog.test.ts | 106 +- .../tests/admin/hacker-detail-dialog.test.tsx | 60 +- .../tests/admin/hacker-filter-facets.test.ts | 11 +- .../admin/hacker-roster-actions.test.tsx | 143 ++ .../challenge-configuration-panel.test.tsx | 6 + .../judging-announcement-editor.test.tsx | 87 +- .../projects/judging-reset-panel.test.tsx | 160 +++ packages/api/src/routers/hacker.ts | 330 ++++- packages/api/src/routers/judging-schedule.ts | 24 +- packages/api/src/routers/judging.ts | 501 ++++++- packages/api/src/tests/hacker/access.test.ts | 17 + .../tests/integration/hacker-guards.test.ts | 302 ++++- .../tests/integration/judging-reset.test.ts | 1154 +++++++++++++++++ .../__snapshots__/api-surface.test.ts.snap | 9 + packages/api/src/utils/audit/coverage.ts | 9 + packages/api/src/utils/hacker/access.ts | 19 +- packages/validators/src/audit.ts | 37 + packages/validators/src/hackers.ts | 31 +- packages/validators/src/judging.ts | 8 + packages/validators/src/tests/hackers.test.ts | 51 +- 43 files changed, 4540 insertions(+), 528 deletions(-) create mode 100644 .forge/features/judging-command-center-reset/spec.md create mode 100644 .forge/features/judging-command-center-reset/srd.md create mode 100644 .forge/features/judging-command-center-reset/status.md create mode 100644 .forge/features/judging-command-center-reset/test-cases.md create mode 100644 apps/blade/src/app/_components/judging/judging-launch-checklist.tsx create mode 100644 apps/blade/src/app/_components/judging/judging-reset-panel.tsx create mode 100644 apps/blade/src/tests/admin/hacker-roster-actions.test.tsx create mode 100644 apps/blade/src/tests/projects/judging-reset-panel.test.tsx create mode 100644 packages/api/src/tests/integration/judging-reset.test.ts diff --git a/.forge/features/hacker-management/spec.md b/.forge/features/hacker-management/spec.md index c795d345d..8d2c2f100 100644 --- a/.forge/features/hacker-management/spec.md +++ b/.forge/features/hacker-management/spec.md @@ -21,8 +21,10 @@ the readiness signal that slice computed finally means something. - **Hacker readers** — users granted Read Hackers can browse, search, filter, and open applications, including their event attendance. They cannot edit. - **Hacker editors** — users granted Edit Hackers can also correct applications, - adjust points, change statuses singly or in bulk, and delete applications. -- **Officers** — retain full access, including blacklist information and controls. + adjust points, change mail-sending statuses singly or in bulk, and delete + applications. +- **Officers** — retain full access, including blacklist information and + controls, and can bulk check in selected hackers. - **Applicants** — never see this screen. They experience it only as the mail a transition sends. They are the reason the guards exist. @@ -46,7 +48,7 @@ the configuration screen. - A table of applicants: name, email, school, status, points, and a blacklist marker. Searchable by name and email, filterable by status, school, level of - study, and graduation year. + study, and graduation year. Checked-In is one of the status filters. - **Bulk is the primary flow.** Filter the roster down to the group you mean — UCF undergraduates, say — then sweep across the rows and act on them together. Accepting people one at a time is possible but is not what this screen is @@ -70,7 +72,10 @@ invisible to the applicant. - Roster: list, search, filter by status, for one hackathon. - Per-hacker status transitions, which send the configured status mail. -- Bulk accept and bulk deny, best-effort, with a per-hacker result report. +- Bulk status changes, including an officer-only Checked-In action, best-effort, + with a per-hacker result report. Checked-In sends no email. +- Permanent deletion from either an application detail or a selected group, + with preview and confirmation for the bulk action. Deletion sends no email. - Soft blacklist: a per-hackathon, officer-only flag that does **not** change status. - The readiness gate: transitions that would send mail are refused while the @@ -79,8 +84,8 @@ invisible to the applicant. ### Out of scope -- **Check-in.** Moved to the event page slice, with class assignment and live - Discord role application. +- **Event check-in side effects.** Class assignment, event attendance, points, + and live Discord role application remain on the event page. - **Granting points.** The column is displayed; nothing here writes it. Awarding arrives with events. - **Creating hackers.** This slice operates on rows that already exist. New @@ -91,8 +96,8 @@ invisible to the applicant. ## Vocabulary -- **Status** — one of the seven application states. Six send mail; `checkedin` - does not, and is not reachable from this screen at all. +- **Status** — one of the seven application states. Six send mail. Officers can + bulk move selected hackers to `checkedin` without sending mail. - **Capacity reject** — the officer-facing name for transitioning to `denied`. The status is stored as `denied`; the mail the applicant receives is the capacity template. Officers see "capacity"; applicants never see "denied". @@ -144,7 +149,9 @@ invisible to the applicant. the officer is told which status is missing. - AC-006 A transition that would send mail is refused while the hackathon is not fully configured. Blacklisting and viewing stay available. -- AC-007 `checkedin` is not reachable from this screen. +- AC-007 Checked-In appears as a roster status filter. Officers can bulk move + selected hackers to Checked-In through preview and confirmation; no email is + queued, and delegated Hacker Editors cannot use this transition. - AC-008 The mail an applicant receives is the one an officer previewed in the configuration screen — same template, same subject, same rendered fields. - AC-009 **Revised.** The status change and the queuing of its mail succeed or @@ -176,12 +183,19 @@ invisible to the applicant. **Bulk** -- AC-016 An officer can select many applicants and accept or deny them together. +- AC-016 An officer can select many applicants and change their status together, + including moving them to Checked-In without email. - AC-017 A bulk action processes each applicant independently; one failure does not stop the rest. - AC-018 After a bulk action the officer sees exactly who moved and who did not, and why for each one that did not. - AC-019 A bulk action sends at most one mail per applicant. +- AC-034 A Hacker Editor can permanently delete one application from its detail + view or delete a selected group after previewing exactly who will be removed. + The roster and selection refresh after completion, and no email is sent. +- AC-035 A delegated Hacker Editor cannot delete a blacklisted application; + that row is skipped in bulk without revealing its blacklist. Officers may + delete it. **Honesty** diff --git a/.forge/features/hacker-management/srd.md b/.forge/features/hacker-management/srd.md index ed71bd50c..50434a516 100644 --- a/.forge/features/hacker-management/srd.md +++ b/.forge/features/hacker-management/srd.md @@ -34,10 +34,11 @@ officer-only policy. Existing permission bits are reused; no migration is needed - `READ_HACKERS` or `EDIT_HACKERS`: roster options, filters, counts, selection survival, application details, and the existing hacker event-attendance read. -- `EDIT_HACKERS`: profile corrections, point adjustments, single/bulk status - changes (including preview), and application deletion. -- `IS_OFFICER`: overrides the above and is still required for blacklist access - and hackathon configuration. Do not broaden the platform configuration guard. +- `EDIT_HACKERS`: profile corrections, point adjustments, single/bulk + mail-sending status changes (including preview), and application deletion. +- `IS_OFFICER`: overrides the above and is required for blacklist access, + hackathon configuration, and the bulk `checkedin` transition. Do not broaden + the platform configuration guard. - No hacker capability: no navigation, page access, or API access. The API redacts blacklist fields to null for non-officers, rejects blacklist @@ -49,7 +50,9 @@ Officer responses retain their existing shape and values. Blade must gate its admin layout, navigation, and page consistently. It passes read/edit/officer capabilities to the roster and detail controls. Read-only users cannot select rows or see mutations. The event-attendance read uses hacker -read access; general event administration and check-in keep their existing gates. +read access. The roster exposes Checked-In only to officers; event administration +and its class, attendance, point, and Discord side effects keep their existing +gates. Validation covers the actual routers, role unions/revocation, read-only UI, and blacklist redaction/filtering. Rollout is a normal application deployment; @@ -196,14 +199,16 @@ the filtered path has to be as fast as the unfiltered one, and `school` and New `hacker` router, registered in `root.ts` as `api.hacker.*`. -| Procedure | Shape | Notes | -| ------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `listForHackathon` | query(`{hackathonId, search?, status?, cursor?}`) → page | Paginated. 2537 rows exist today; the largest hackathon must not be one read. | -| `statusCounts` | query(`{hackathonId}`) → count per status | One grouped query, not seven. | -| `setStatus` | mutation(`{attendeeId, status}`) | Enqueues the configured mail. Refuses on blacklist and on unconfigured. | -| `previewBulk` | mutation(`{hackathonId, filter, status}`) → who sends, who skips | Takes a **filter**, not an id list. Nothing is written. | -| `confirmBulk` | mutation(`{previewVersion}`) → per-hacker result | Acts on the snapshot the preview took. Best-effort. | -| `setBlacklist` | mutation(`{attendeeId, blacklisted, reason?}`) | Never changes status. Sends nothing. | +| Procedure | Shape | Notes | +| ------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `listForHackathon` | query(`{hackathonId, search?, status?, cursor?}`) → page | Paginated. 2537 rows exist today; the largest hackathon must not be one read. | +| `statusCounts` | query(`{hackathonId}`) → count per status | One grouped query, not seven. | +| `setStatus` | mutation(`{attendeeId, status}`) | Enqueues the configured mail. Refuses on blacklist and on unconfigured. | +| `previewBulk` | mutation(`{hackathonId, attendeeIds, status}`) → who moves, who skips | Nothing is written. `checkedin` requires officer access and no mail config. | +| `confirmBulk` | mutation(`{hackathonId, attendeeIds, status}`) → per-hacker result | Re-resolves selected rows. `checkedin` records time and actor; no mail. | +| `previewBulkDelete` | mutation(`{hackathonId, attendeeIds}`) → who deletes, who skips | Nothing is written. Uses the existing blacklist permission rule. | +| `confirmBulkDelete` | mutation(`{hackathonId, attendeeIds, confirmed:true}`) → result | Hard-deletes in one transaction, clears commands, and sends no mail. | +| `setBlacklist` | mutation(`{attendeeId, blacklisted, reason?}`) | Never changes status. Sends nothing. | Errors: `NOT_FOUND` for unknown ids, `PRECONDITION_FAILED` for a blacklisted accept and for an unconfigured hackathon, `BAD_REQUEST` for validation. @@ -216,11 +221,9 @@ diff; blacklist events carry the reason. ## Validation -New module `packages/validators/src/hackers.ts`, or an extension of the existing -hackathon validators if it stays small. `hackathonSendingStatusSchema` already -exists and is exactly the set reachable here — `checkedin` is excluded by -construction, which is what AC-007 needs, so it should be reused rather than -re-derived. +`packages/validators/src/hackers.ts` reuses `hackathonSendingStatusSchema` for +single and mail-sending transitions. The bulk schema extends that union with +`checkedin`; the API applies the additional officer guard. ## Data / migration / compatibility @@ -248,8 +251,9 @@ here but this slice adds to the watermark. ## Discord integration -None. Class assignment and role application moved to the event slice with -check-in. This slice must not write `classId` or touch a guild. +None. Roster check-in writes only attendee status and check-in attribution. +Class assignment, event attendance, points, and role application remain in the +event slice; this path must not write them or touch a guild. ## Configurability review @@ -290,6 +294,15 @@ Guards get DB-backed integration tests with positive controls, following `hackathon-destructive-guards.test.ts` — a guard test that passes against an unconditionally-refusing guard proves nothing. +Application deletion remains the existing hard-delete model. Single and bulk +paths remove `HackerAttendee`, clear the participant command for that hackathon, +and delete the legacy `Hacker` snapshot only when no attendee still references +it. `HackerProfile` and its revisions remain so the participant can apply again. +Bulk preview and confirmation share target resolution, skip missing rows, and +skip blacklisted rows for delegated editors without disclosing the blacklist. +One bulk audit event names the hackathon and affected attendees. No schema, +migration, restore flow, Deleted tab, or email send is introduced. + ## Resolved: what "enqueued" actually guarantees The async invariant was accepted on the condition that it still means the mail diff --git a/.forge/features/hacker-management/status.md b/.forge/features/hacker-management/status.md index dc9031ccd..aee457d68 100644 --- a/.forge/features/hacker-management/status.md +++ b/.forge/features/hacker-management/status.md @@ -1,7 +1,35 @@ # Hacker Management Status Phase: **implemented** — whole bundle approved 2026-08-03 and built. Awaiting -`forge-review` and the owner's UI pass. +the owner's final UI pass. + +## Officer bulk Checked-In — 2026-09-13 + +- Owner approved a Checked-In roster filter and an officer-only bulk Checked-In + transition. It keeps the existing preview/confirm flow but does not require + status-mail configuration or create an email send. +- The roster path records `status`, `checkedInAt`, and `checkedInBy`. Event + attendance, class assignment, points, and Discord side effects remain out of + scope. +- Targeted validation: validators 20 tests, Blade filter facets 16 tests, and + API hacker guards 57 tests passed. Validator, API, and Blade typechecks passed. + Targeted formatting and `git diff --check` passed. Targeted ESLint passed with + the repository's existing file/function-length and import-style warnings after + the first run hit Node's default heap limit. + +## Individual and bulk deletion — 2026-09-13 + +- Owner clarified that deletion remains permanent: no Deleted tab, restore + flow, schema change, or migration. +- The existing detail action remains available to Hacker Editors. A selected + group now uses preview and confirmation before the same hard-delete cleanup; + reusable profiles remain, and no email is sent. +- The existing blacklisted-row safeguard remains: delegated editors receive a + redacted skip, while officers may delete the application. +- Delete is a separate destructive action in both the selected-row action bar + and the hacker detail dialog; it is not represented as a roster status. +- Final targeted validation passed: 178 API tests and 41 Blade tests. The full + pre-commit gate and all 21 workspace build tasks passed. ## Delegated hacker permissions — 2026-09-06 @@ -50,8 +78,9 @@ officer-facing surface. **Scope, from the first reverse-prompt round:** -- **Check-in moved out**, to the event page slice, taking class assignment and - live Discord role application with it. This was originally in this slice. +- **Event check-in moved out**, taking class assignment, attendance, points, and + live Discord role application with it. The later officer roster action only + moves selected attendee records to Checked-In and records attribution. - **Points moved in**, but read-only — the column is displayed and nothing here writes it. Awarding arrives with events. - **Bulk accept/deny is in.** @@ -134,7 +163,7 @@ not specified. - [x] Roster UI with amendable multi-select, filters, bulk preview/confirm, blacklist, and the failed-delivery surface. - [x] Tests, **including both files the previous bundle promised and skipped**. -- [ ] `forge-review` until clean. +- [x] `forge-review` until clean. - [ ] Owner UI pass. ## Deviations from the approved SRD diff --git a/.forge/features/hacker-management/test-cases.md b/.forge/features/hacker-management/test-cases.md index 9be30bf95..bdb42b227 100644 --- a/.forge/features/hacker-management/test-cases.md +++ b/.forge/features/hacker-management/test-cases.md @@ -15,8 +15,9 @@ Intentionally excluded: - **Actual delivery.** The provider gateway has a fake (`packages/email/src/provider.ts`); these cases assert what is _enqueued_ and what the pipeline records, never that Listmonk sent anything. -- **Check-in.** Moved to the event slice. `checkedin` appears here only as a - status that must be unreachable (TC-NEG-004). +- **Event check-in side effects.** Class assignment, event attendance, points, + and Discord role application remain in the event slice. The roster path only + changes attendee status and check-in attribution. - **Awarding points.** Read-only this slice; the only assertion is that nothing writes the column. - **The cron itself.** `runEmailDeliveryCycle` is pre-existing and tested by @@ -183,6 +184,20 @@ makes the symptom impossible. If someone later "simplifies" the roster by joining through the recipient snapshot, this fails immediately rather than months later when retention first bites. +### TC-022: Bulk deletion previews and permanently removes the selection (AC-034, AC-035) + +Setup: two ordinary applications and one blacklisted application, with a +delegated Hacker Editor and an officer. + +Action: preview and confirm deletion of the ordinary selection; preview the +blacklisted row as the editor. + +Expected: the preview names the ordinary applications, confirmation removes +their attendee rows and participant commands, orphaned legacy Hacker snapshots +are removed, reusable profiles remain, and no email send is created. The editor +sees the blacklisted row as an undisclosed skip; an officer may delete it. Blade +states that deletion is permanent and sends no email. + ### TC-015: The selection is amendable (AC-027) Setup: a filtered roster spanning more than one page. @@ -313,15 +328,18 @@ Action: render. Expected: every action disabled except capacity reject and un-blacklist. Asserted by accessible name, not by class or `data-*`. -### TC-NEG-004: `checkedin` is unreachable (AC-007) +### TC-NEG-004: Checked-In is limited to officer bulk actions (AC-007) -Setup: a configured hackathon. +Setup: selected hackers in a hackathon without complete status-mail +configuration; one officer and one delegated Hacker Editor. -Action: attempt a transition to `checkedin`. +Action: filter to Checked-In; have the officer preview and confirm a bulk +Checked-In transition; have the editor attempt the same transition. -Expected: rejected at the input boundary. `hackathonSendingStatusSchema` already -excludes it, so this proves the router uses that schema rather than the wider -one — which is the mistake worth catching. +Expected: the filter is available. The officer preview succeeds, confirmation +sets `status`, `checkedInAt`, and `checkedInBy`, and no email send is created. +The editor receives `FORBIDDEN`. The single-applicant mail action still rejects +`checkedin` at the input boundary. ### TC-NEG-005: An unconfigured hackathon blocks mail-sending transitions (AC-006) diff --git a/.forge/features/judging-command-center-reset/spec.md b/.forge/features/judging-command-center-reset/spec.md new file mode 100644 index 000000000..c45ea14bd --- /dev/null +++ b/.forge/features/judging-command-center-reset/spec.md @@ -0,0 +1,48 @@ +# Judging Command Center Reset Spec + +Status: Approved + +## User-facing purpose + +Give officers a clear, ordered path from project import to live judging, plus safe ways to undo test data before the hackathon starts. + +## Users / actors + +- Officers who can manage project judging. +- Judges and hackers are affected by resets but cannot run them. + +## User-visible interface + +- The command center follows a numbered flow: Projects, Rubric, Rooms, Schedule, then Launch. +- A launch checklist links to each required section and marks steps green when complete or ready. +- Evaluations can be dropped without changing the project inventory, rooms, schedule, or launch settings. +- The Reset tab exposes granular cleanup for each judging slice and one full reset. +- Individual rooms can be permanently deleted when they have no saved appointments. +- Scheduler failures use a dialog instead of inline warning cards. Routine judging announcements use a compact neutral notification; urgent announcements remain blocking dialogs. + +## Scope + +### In scope + +- Hackathon-scoped judging projects, groups, rubric, rooms/access, schedules, evaluations, deliberation, claims, announcements, judges, and toggle settings. +- Typed confirmation for destructive actions. +- Audit records for every new destructive operation. + +### Out of scope + +- Deleting the hackathon. +- Deleting global judging buildings. +- Deleting or archiving Discord threads that already exist outside Forge. + +## Acceptance criteria + +- Full reset returns the selected hackathon to starter General/MLH groups with all judging toggles at their defaults. +- Full reset is atomic and includes all data removed by granular actions. +- Granular reset operations preserve unrelated judging slices and explain dependency order when blocked. +- Room deletion removes its Blade link, guest sessions, presence, and announcement but refuses while appointments reference it. +- The checklist opens the relevant tab/section and accurately reflects imported projects, configured setup, linked rooms, schedule readiness/save, judging state, and claim-link delivery. +- Failed scheduler generation and refresh messages do not shift the schedule layout. + +## Open questions + +- None. The user delegated deletion order and layout details in the implementation request. diff --git a/.forge/features/judging-command-center-reset/srd.md b/.forge/features/judging-command-center-reset/srd.md new file mode 100644 index 000000000..78ece3dd7 --- /dev/null +++ b/.forge/features/judging-command-center-reset/srd.md @@ -0,0 +1,62 @@ +# Judging Command Center Reset SRD + +Status: Approved + +## Technical purpose + +Add audited, officer-only judging reset procedures and reorganize the Blade command center around the actual launch sequence. + +## Relevant principles + +- [Authorization and permissions](../../../docs/agentic-development/forge-engineering-principles.md#6-authorization-and-permissions) +- [Data and transaction boundaries](../../../docs/agentic-development/forge-engineering-principles.md#7-data-and-transaction-boundaries) +- [React and Next.js](../../../docs/agentic-development/forge-engineering-principles.md#8-react-and-nextjs) + +## Access policy + +Every reset/delete procedure uses `permProcedure` and `assertCanManageProjects`. Judges, hackers, and unauthenticated callers are rejected before a transaction begins. + +## Architecture / data flow + +- `@forge/validators` owns typed destructive inputs and audit catalog entries. +- `@forge/api` locks the selected hackathon or room, validates confirmation and dependencies, deletes in foreign-key-safe order, and writes an aggregate audit event in the same transaction. +- Blade renders dialogs and refreshes server-owned command-center data after success. + +## tRPC/API behavior + +- `judging.dropEvaluations`: deletes drafts/evaluations and clears `firstResultAt` so the saved schedule can be dropped again. +- `judging.dropRooms`: deletes all rooms and room-scoped access data after the schedule is gone. +- `judging.resetSetup`: restores starter groups and clears the rubric after downstream rooms, schedules, and feedback are gone. +- `judging.resetLaunch`: clears claims/claim links and restores publication, visibility, and judging-state defaults. +- `judging.resetHackathon`: performs the full reset atomically. +- `judging.deleteRoom`: permanently deletes one unreserved room. + +## Validation + +Hackathon resets require the exact hackathon display name. Room deletion requires the exact room name. Inputs are length-bounded UUID/name schemas. + +## Data / migration / compatibility + +No schema or migration is required. Full reset preserves the Hackathon, global JudgingBuilding rows, users, and append-only admin audit data. Existing Discord threads are not modified. + +## Discord integration + +Reset deletes Forge room/thread references and room access data. It does not make external Discord deletion calls inside the database transaction. + +## Configurability review + +No annual code change is required. Starter judging groups continue to come from the existing default group configuration. + +## React / frontend constraints + +The admin page remains server-first. Client components own dialogs, optimistic tab navigation, and mutations. Existing Blade tokens/components and 44px controls are retained. Error dialogs open once per scheduler job/error key and do not alter page flow. + +## Testing / verification strategy + +- Disposable PostgreSQL integration coverage for granular evaluation cleanup, room deletion constraints/cascades, exact confirmation, and full reset defaults. +- Blade component tests for affected room/setup fixtures and browser testing of desktop/mobile command-center states. +- API surface, audit coverage, React analysis, lint, typecheck, build, and precommit gates. + +## Open questions + +- None. diff --git a/.forge/features/judging-command-center-reset/status.md b/.forge/features/judging-command-center-reset/status.md new file mode 100644 index 000000000..f3c3b3cfe --- /dev/null +++ b/.forge/features/judging-command-center-reset/status.md @@ -0,0 +1,43 @@ +# Judging Command Center Reset Status + +Current phase: Awaiting owner UI approval + +## Decision log + +- 2026-09-13: The user approved a numbered command-center flow, granular destructive operations, and a full judging reset. +- 2026-09-13: Full reset preserves the Hackathon, global buildings, audit history, and external Discord threads. +- 2026-09-13: Routine alerts must not occupy document flow; urgent announcements remain blocking dialogs. +- 2026-09-13: Project inventory, visibility, import, deletion, search, and filters share one compact control surface. +- 2026-09-13: Every granular destructive action is a direct Reset action; navigation-only tiles were removed. + +## Open questions + +- None. + +## Task list + +- [x] Confirm product scope and destructive boundaries. +- [x] Document API/data order and observable test cases. +- [x] Implement reset procedures and initial command-center layout. +- [x] Complete component, integration, and browser validation for judging scope. +- [x] Complete final Forge review after the owner's layout refinements. +- [ ] Show desktop/mobile screenshots to the user before opening the PR. + +## Validation / commands + +- `pnpm --filter=@forge/api test -- src/tests/integration/judging-reset.test.ts`: passed (4 tests). +- `pnpm --filter=@forge/blade test -- src/tests/projects/judging-announcement-editor.test.tsx`: passed (4 tests). +- `pnpm verify:precommit`: passed after the owner's layout refinements (React analysis, format, lint, and 33 workspace typecheck tasks). +- `pnpm build`: passed (21 workspace build tasks). +- Targeted API validation: 178 tests passed across judging reset, hacker guards, access, and API surface coverage. +- Targeted Blade validation: 41 tests passed across reset routing, hacker actions, filtering, room announcements, and challenge setup. +- Manual local browser QA passed for desktop/mobile command-center layout, checklist/reset dialogs, scheduler failures, room deletion, and routine announcements. Captures remain outside the repository under `/tmp`. +- Production browser QA confirmed the authenticated judge project page, historical hackathon dropdown, and guest activation redirect to the public Blade origin. +- Manual browser QA passed for the compact project toolbar and six consistently labelled granular Reset actions. +- Final review caught and resolved schedule-preview cleanup, locked project reset, project claim/deliberation FK ordering, stale lock attribution, reset availability, and destructive-action grouping. The follow-up review found no remaining production issues. + +## Links + +- PRs: +- Issues: +- Discord/thread context: diff --git a/.forge/features/judging-command-center-reset/test-cases.md b/.forge/features/judging-command-center-reset/test-cases.md new file mode 100644 index 000000000..810b8acfc --- /dev/null +++ b/.forge/features/judging-command-center-reset/test-cases.md @@ -0,0 +1,50 @@ +# Judging Command Center Reset Test Cases + +Status: Approved + +## Scope + +Prove reset isolation, full-reset completeness, destructive guards, checklist navigation/readiness, and non-shifting scheduler/announcement alerts. + +## Test placement plan + +- API/database: `packages/api/src/tests/integration/judging-reset.test.ts` +- Blade components: `apps/blade/src/tests/projects/*judging*.test.tsx` +- Browser: local Blade command center at desktop and mobile widths. + +## Test cases + +### TC-001: Drop evaluations only + +Given a saved schedule with a submitted evaluation and draft, dropping evaluations removes their answers/revisions and clears the schedule's first-result lock while preserving the schedule, projects, and rooms. + +### TC-002: Delete one room + +An unreserved room is permanently deleted with its access rows. A room referenced by an appointment is rejected. + +### TC-003: Full judging reset + +Given populated judging data, full reset removes every hackathon-scoped judging resource, restores starter groups/default toggles, and preserves the hackathon/global buildings/audit history. + +### TC-004: Granular dependency safety + +Room/setup/project cleanup reports a specific precondition error when downstream records still depend on it. + +### TC-005: Launch checklist + +Each step links to its owning tab/section. Completed or ready steps are green and the displayed count matches the readiness data. + +### TC-006: Non-shifting alerts + +An infeasible/incomplete schedule opens a dismissible error dialog once for that job. Routine judging announcements appear as compact neutral notifications; urgent announcements require explicit acknowledgement in a dialog. + +## Negative / regression cases + +- Wrong typed confirmation deletes nothing and writes no audit event. +- A reset for one hackathon does not change another hackathon. +- Judges cannot call destructive officer procedures. +- Existing project/schedule drop flows remain available. + +## Open questions + +- None. diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx index 18505acb1..2fc2e185d 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect } from "react"; -import { Loader2, Send } from "lucide-react"; +import { Loader2, Send, Trash2, UserCheck } from "lucide-react"; -import type { SkipReason } from "@forge/validators"; +import type { HackerBulkStatus, SkipReason } from "@forge/validators"; import { Button } from "@forge/ui/button"; import { Dialog, @@ -18,7 +18,34 @@ import { HACKER_STATUS_LABELS } from "@forge/validators"; import { api } from "~/trpc/react"; -type SendingStatus = keyof typeof HACKER_STATUS_LABELS; +export type HackerBulkAction = HackerBulkStatus | "delete"; + +export function bulkActionCopy(action: HackerBulkAction, count: number) { + if (action === "delete") { + return { + confirm: `Delete ${count} applications`, + description: + "This permanently removes their applications and hackathon activity. Their accounts and reusable profiles remain. No email is sent.", + label: "Will be permanently deleted", + title: "Delete", + }; + } + if (action === "checkedin") { + return { + confirm: `Check in ${count} hackers`, + description: "This checks them into the hackathon without sending email.", + label: "Will be checked in", + title: HACKER_STATUS_LABELS[action], + }; + } + return { + confirm: `Send ${count} emails`, + description: + "This sends each of them the configured email immediately. It cannot be recalled.", + label: "Will be emailed", + title: HACKER_STATUS_LABELS[action], + }; +} /** * Read through a widening helper, the same way `hacker-table.tsx` reads status @@ -60,9 +87,8 @@ const SKIP_REASONS: Record = { }; /** - * Preview, then confirm — the same two-step the email portal uses for a - * campaign, because this is the same act: a lot of mail leaving at once, - * unrecallable. + * Preview, then confirm. Status changes queue their configured email; check-in + * and permanent deletion use the same confirmation flow but do not send mail. * * The preview writes nothing. It exists so an officer sees exactly who is about * to be mailed and who is being skipped *before* committing, rather than @@ -73,22 +99,32 @@ export function BulkConfirmDialog({ hackathonId, onDone, onOpenChange, - status, + action, }: { + action: HackerBulkAction | null; attendeeIds: string[]; hackathonId: string; onDone: () => void; onOpenChange: (open: boolean) => void; - status: SendingStatus | null; }) { - const preview = api.hacker.previewBulk.useMutation({ + const statusPreview = api.hacker.previewBulk.useMutation({ + onError: (error) => toast.error(error.message), + }); + const deletePreview = api.hacker.previewBulkDelete.useMutation({ onError: (error) => toast.error(error.message), }); - const confirm = api.hacker.confirmBulk.useMutation({ + const statusConfirm = api.hacker.confirmBulk.useMutation({ onError: (error) => toast.error(error.message), onSuccess: (result) => { const skippedNote = result.skipped.length > 0 ? `, ${result.skipped.length} skipped` : ""; + if (action === "checkedin") { + toast.success( + `${result.movedCount} checked in${skippedNote}. No email sent.`, + ); + onDone(); + return; + } // The withheld note only ever appears outside production, where sends are // narrowed to the team. Saying "queued" with nothing queued is how a live // test looked successful and mailed nobody. @@ -99,15 +135,27 @@ export function BulkConfirmDialog({ onDone(); }, }); + const deleteConfirm = api.hacker.confirmBulkDelete.useMutation({ + onError: (error) => toast.error(error.message), + onSuccess: (result) => { + const skippedNote = + result.skipped.length > 0 ? `, ${result.skipped.length} skipped` : ""; + toast.success( + `${result.deletedCount} applications deleted${skippedNote}. No email sent.`, + ); + onDone(); + }, + }); - const open = status !== null; + const open = action !== null; // An effect, not a render-phase call. `previewBulk` is a network request, and // firing it during render means StrictMode's double-invoke sends it twice — // and a render discarded by a parent transition sends it anyway. The // `isPending` guard could not see either, because it reads a render-time // snapshot. - const { mutate: runPreview, reset: resetPreview } = preview; + const { mutate: runStatusPreview, reset: resetStatusPreview } = statusPreview; + const { mutate: runDeletePreview, reset: resetDeletePreview } = deletePreview; /** * The selection's contents, as a value the dependency array can compare. * @@ -120,8 +168,9 @@ export function BulkConfirmDialog({ */ const idsKey = attendeeIds.join(","); useEffect(() => { - if (status === null) { - resetPreview(); + if (action === null) { + resetStatusPreview(); + resetDeletePreview(); return; } // `previewBulk` requires at least one id. If the selection empties while @@ -129,35 +178,73 @@ export function BulkConfirmDialog({ // in place of the preview, and retrying fails the same way — so it says // what happened instead. if (attendeeIds.length === 0) { - resetPreview(); + resetStatusPreview(); + resetDeletePreview(); + return; + } + if (action === "delete") { + resetStatusPreview(); + runDeletePreview({ attendeeIds, hackathonId }); return; } - runPreview({ attendeeIds, hackathonId, status }); - }, [attendeeIds, hackathonId, idsKey, resetPreview, runPreview, status]); + resetDeletePreview(); + runStatusPreview({ attendeeIds, hackathonId, status: action }); + }, [ + action, + attendeeIds, + hackathonId, + idsKey, + resetDeletePreview, + resetStatusPreview, + runDeletePreview, + runStatusPreview, + ]); - const result = preview.data; + const deleting = action === "delete"; + const checkingIn = action === "checkedin"; + const previewError = deleting ? deletePreview.error : statusPreview.error; + const previewPending = deleting + ? deletePreview.isPending + : statusPreview.isPending; + const result = deleting ? deletePreview.data : statusPreview.data; + const affected = + (deleting ? deletePreview.data?.deleting : statusPreview.data?.sending) ?? + []; + const skipped = result?.skipped ?? []; + const hasResult = result !== undefined; + const copy = action ? bulkActionCopy(action, affected.length) : null; + const confirmPending = deleting + ? deleteConfirm.isPending + : statusConfirm.isPending; return ( - {status ? HACKER_STATUS_LABELS[status] : ""}{" "} - {result ? `${result.sending.length} applicants` : "applicants…"} + {copy?.title ?? ""}{" "} + {hasResult ? `${affected.length} applicants` : "applicants…"} - This sends each of them the configured email immediately. It cannot - be recalled. + {copy?.description ?? "Preparing this bulk action."} - {preview.isError ? ( + {previewError ? (
-

{preview.error.message}

+

{previewError.message}

diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx index 4906be730..a2eab1620 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-detail-dialog.tsx @@ -34,7 +34,10 @@ import { Label } from "@forge/ui/label"; import { Skeleton } from "@forge/ui/skeleton"; import { Textarea } from "@forge/ui/textarea"; import { toast } from "@forge/ui/toast"; -import { HACKER_STATUS_LABELS } from "@forge/validators"; +import { + HACKATHON_SENDING_STATUSES, + HACKER_STATUS_LABELS, +} from "@forge/validators"; import { DetailRow, @@ -50,7 +53,7 @@ import { } from "./first-time-status"; import { HackerEventAttendancePanel } from "./hacker-event-attendance-panel"; -type SendingStatus = keyof typeof HACKER_STATUS_LABELS; +type HackerStatus = keyof typeof HACKER_STATUS_LABELS; /** Only what the edit form reads, so it does not depend on the whole DTO. */ interface HackerEditable { @@ -75,14 +78,14 @@ interface HackerEditable { websiteUrl: string | null; } -/** `checkedin` is filterable but not settable, so it has no label entry. */ +/** Safely label a stored status, including stale values from an open tab. */ function statusLabel(status: string) { // `hasOwn`, not `in`, which walks the prototype and would return a function // for a wire value like `"toString"` — React throws on that, so an officer // would get an error boundary instead of this fallback. return Object.hasOwn(HACKER_STATUS_LABELS, status) - ? HACKER_STATUS_LABELS[status as SendingStatus] - : "Checked in"; + ? HACKER_STATUS_LABELS[status as HackerStatus] + : "Unknown status"; } function HackerDetailSkeleton() { @@ -443,9 +446,7 @@ export function HackerDetailDialog({ title="Status and actions" >
- {( - Object.keys(HACKER_STATUS_LABELS) as SendingStatus[] - ).map((status) => ( + {HACKATHON_SENDING_STATUSES.map((status) => ( - ), - )} + {HACKATHON_SENDING_STATUSES.map((status) => ( + + ))} + {isOfficer ? ( + + ) : null} + + Actions + + - ) : ( - - )} -
-
- - -
-
-
- -

- Reveal scoped ratings to authenticated judges before they - submit. Guest judges stay gated. -

-
- { - try { - await setResults.mutateAsync({ - displayAllResults: checked, - hackathonId: data.hackathon.id, - }); - toast.success( - checked ? "Results revealed." : "Results gated.", - ); - router.refresh(); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Result visibility update failed.", - ); - } - }} - /> -
-
- - {rubricLocked ? ( Rubric locked @@ -379,3 +285,104 @@ export function JudgingConfigurationPanel({ data }: { data: ControlData }) { ); } + +export function JudgingLaunchControls({ data }: { data: ControlData }) { + const router = useRouter(); + const setState = api.judging.setJudgingState.useMutation(); + const setResults = api.judging.setDisplayAllResults.useMutation(); + + async function changeState(state: "closed" | "open") { + try { + await setState.mutateAsync({ hackathonId: data.hackathon.id, state }); + toast.success( + state === "open" ? "Judging is open." : "Judging is closed.", + ); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "State update failed.", + ); + } + } + + return ( +
+
+
+
+
+

Judging state

+ + {data.configuration.state[0]?.toUpperCase()} + {data.configuration.state.slice(1)} + +
+

+ Draft blocks submissions. Open accepts new and edited scores. + Closed keeps every submission readable but locks changes. +

+
+
+ {data.configuration.state === "open" ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+ +

+ Reveal scoped ratings to authenticated judges before they submit. + Guest judges stay gated. +

+
+ { + try { + await setResults.mutateAsync({ + displayAllResults: checked, + hackathonId: data.hackathon.id, + }); + toast.success(checked ? "Results revealed." : "Results gated."); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Result visibility update failed.", + ); + } + }} + /> +
+
+
+ ); +} diff --git a/apps/blade/src/app/_components/judging/judging-control-panel.tsx b/apps/blade/src/app/_components/judging/judging-control-panel.tsx index a522cbcab..55643dfc5 100644 --- a/apps/blade/src/app/_components/judging/judging-control-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-control-panel.tsx @@ -22,6 +22,7 @@ import { RefreshCw, Send, ShieldAlert, + Trash2, UserRoundX, UsersRound, } from "lucide-react"; @@ -535,7 +536,7 @@ function AnnouncementDialogContent({ {current ? ( -
+
A current {current.isUrgent ? "urgent dialog" : "banner"} is live for {scope}. Publishing replaces it immediately.
@@ -589,6 +590,8 @@ export function JudgingControlPanel({ null, ); const [archiving, setArchiving] = useState(null); + const [deleting, setDeleting] = useState(null); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); const [announcementTarget, setAnnouncementTarget] = useState( null, ); @@ -609,6 +612,7 @@ export function JudgingControlPanel({ const rotate = api.judging.rotateRoomLink.useMutation(); const move = api.judging.moveRoom.useMutation(); const archive = api.judging.archiveRoom.useMutation(); + const deleteRoom = api.judging.deleteRoom.useMutation(); const revokeGuest = api.judging.revokeGuest.useMutation(); const removeJudge = api.judging.removeJudgeFromRoom.useMutation(); const data = query.data; @@ -762,8 +766,9 @@ export function JudgingControlPanel({ Room setup locked - A saved schedule locks room changes. Open the Schedule tab and drop - the eligible schedule before changing rooms. + A saved schedule locks room layout changes. Unreserved rooms can + still be permanently deleted; drop the eligible schedule to edit, + create, or archive rooms. ) : null} @@ -952,7 +957,7 @@ export function JudgingControlPanel({ {data.globalAnnouncement ? ( -
+
@@ -1221,8 +1226,23 @@ export function JudgingControlPanel({ > Archive +
- ) : null} + ) : ( + + )}
@@ -1433,6 +1453,76 @@ export function JudgingControlPanel({
+ { + if (!open && !deleteRoom.isPending) { + setDeleting(null); + setDeleteConfirmation(""); + } + }} + > + + + Permanently delete {deleting?.name}? + + This deletes the room, its Blade access link, guest sessions, + roster presence, and room announcement. Its Discord thread stays + in Discord. This cannot be undone. + + +
+ + setDeleteConfirmation(event.target.value)} + value={deleteConfirmation} + /> +
+ + + + +
+
); } diff --git a/apps/blade/src/app/_components/judging/judging-launch-checklist.tsx b/apps/blade/src/app/_components/judging/judging-launch-checklist.tsx new file mode 100644 index 000000000..96c8c0599 --- /dev/null +++ b/apps/blade/src/app/_components/judging/judging-launch-checklist.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { ArrowRight, Check, Circle, ClipboardCheck } from "lucide-react"; + +import type { RouterOutputs } from "@forge/api"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; + +type ControlData = RouterOutputs["judging"]["listAdmin"]; + +export function JudgingLaunchChecklist({ + data, + onNavigate, +}: { + data: ControlData; + onNavigate: (tab: string, section?: string) => void; +}) { + const [open, setOpen] = useState(false); + const activeRooms = data.rooms.filter((room) => !room.archivedAt); + const setupReady = + data.rubric.length > 0 && + data.challenges.some( + (challenge) => challenge.isGroup && challenge.isScheduled, + ); + const roomAccessReady = + activeRooms.length > 0 && + activeRooms.every((room) => room.buildingId && room.activeLinkId); + const scheduleReady = + data.inventory.projectCount > 0 && setupReady && roomAccessReady; + const judgingOpen = data.configuration.state === "open"; + const steps = [ + { + complete: data.inventory.projectCount > 0, + description: "Load the final Devpost CSV into this hackathon.", + label: "Import projects", + section: "project-inventory", + tab: "projects", + }, + { + complete: setupReady, + description: "Choose scheduled groups and save at least one rubric item.", + label: "Configure challenges and rubric", + section: "challenge-setup-title", + tab: "setup", + }, + { + complete: roomAccessReady, + description: "Give every active room a building and guest access link.", + label: "Configure and distribute room access", + section: "judging-rooms", + tab: "rooms", + }, + { + complete: scheduleReady, + description: + "Inventory, rubric, and room access are ready for generation.", + label: "Begin generating the schedule", + section: "schedule-workspace", + tab: "schedule", + }, + { + complete: data.hasSavedSchedule, + description: "Save the candidate that judges and hackers will use.", + label: "Save the schedule", + section: "schedule-workspace", + tab: "schedule", + }, + { + complete: judgingOpen, + description: "Allow judges to submit and edit evaluations.", + label: "Open judging to judges", + section: "judging-state", + tab: "launch", + }, + { + complete: data.inventory.claimLinksSent, + description: `Send claim links to all ${data.inventory.memberCount} imported team members.`, + label: "Email claim links to hackers", + section: "hacker-access", + tab: "launch", + }, + { + complete: data.hasSavedSchedule && judgingOpen, + description: + "Monitor rooms, appointments, and exceptions during judging.", + label: "Watch the schedule", + section: "schedule-workspace", + tab: "schedule", + }, + ]; + const completed = steps.filter((step) => step.complete).length; + + return ( + <> + + + + + Judging launch checklist + + Follow the setup in order. A green step is complete or ready. + + + +
    + {steps.map((step, index) => ( +
  1. + +
  2. + ))} +
+
+
+ + ); +} diff --git a/apps/blade/src/app/_components/judging/judging-reset-panel.tsx b/apps/blade/src/app/_components/judging/judging-reset-panel.tsx new file mode 100644 index 000000000..074da0fe5 --- /dev/null +++ b/apps/blade/src/app/_components/judging/judging-reset-panel.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { useState } from "react"; +import { AlertTriangle, Loader2, RotateCcw, Trash2 } from "lucide-react"; + +import type { RouterOutputs } from "@forge/api"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; +import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; +import { toast } from "@forge/ui/toast"; + +import { useNavigationRouter as useRouter } from "~/app/_components/shared/route-transition-link"; +import { api } from "~/trpc/react"; + +type ControlData = RouterOutputs["judging"]["listAdmin"]; +type Action = + | "evaluations" + | "full" + | "launch" + | "projects" + | "rooms" + | "schedule" + | "setup"; + +const actionCopy: Record< + Action, + { button: string; description: string; pending: string; title: string } +> = { + evaluations: { + button: "Drop evaluations", + description: + "Deletes every submitted evaluation, draft, answer, and revision. Projects, rooms, schedule, and settings stay in place.", + pending: "Dropping evaluations", + title: "Drop all evaluations?", + }, + rooms: { + button: "Drop rooms and access", + description: + "Deletes all rooms, active guest links, guest sessions, presence, and judging announcements. Drop the schedule first.", + pending: "Dropping room setup", + title: "Drop all room configuration?", + }, + projects: { + button: "Reset projects", + description: + "Permanently deletes every active and deleted project, team contact, and imported challenge. Judging groups and their settings stay in Forge.", + pending: "Resetting projects", + title: "Reset the project inventory?", + }, + schedule: { + button: "Reset schedule", + description: + "Deletes the saved schedule, reservations, and unsent schedule drafts. Submitted evaluations stay in place. This is unavailable after the first scheduled result.", + pending: "Resetting schedule", + title: "Reset the judging schedule?", + }, + setup: { + button: "Reset challenge and rubric setup", + description: + "Deletes the rubric and custom judging groups, restores the starter groups, and leaves imported projects in place. Drop the schedule, evaluations, and rooms first.", + pending: "Resetting setup", + title: "Reset challenge and rubric setup?", + }, + launch: { + button: "Reset launch settings", + description: + "Closes judging and the hacker schedule, hides results, clears claim links and claims, and restores launch settings to draft defaults.", + pending: "Resetting launch", + title: "Reset launch and hacker access?", + }, + full: { + button: "Reset all judging", + description: + "Deletes projects, evaluations, schedules, rooms, access links, claims, rubric, challenge configuration, judge sessions, deliberation, and announcements. Every judging toggle returns to its default. The hackathon and global buildings remain.", + pending: "Resetting all judging", + title: "Reset all judging for this hackathon?", + }, +}; + +function ConfirmationDialog({ + action, + hackathonName, + onClose, + onConfirm, + pending, +}: { + action: Action | null; + hackathonName: string; + onClose: () => void; + onConfirm: (confirmation: string) => void; + pending: boolean; +}) { + const [confirmation, setConfirmation] = useState(""); + const copy = action ? actionCopy[action] : null; + return ( + { + if (!open && !pending) { + setConfirmation(""); + onClose(); + } + }} + > + + + {copy?.title} + + {copy?.description} This cannot be undone. + + +
+
+
+
+ + setConfirmation(event.target.value)} + onPaste={(event) => { + event.preventDefault(); + toast.info( + "Please type the hackathon name instead of pasting it.", + ); + }} + placeholder={hackathonName} + value={confirmation} + /> +
+
+ + + + +
+
+ ); +} + +export function DropEvaluationsButton({ + data, + onDropped, +}: { + data: ControlData; + onDropped: () => void; +}) { + const [open, setOpen] = useState(false); + const drop = api.judging.dropEvaluations.useMutation({ + onSuccess(result) { + toast.success( + `Dropped ${result.evaluationCount} evaluation${result.evaluationCount === 1 ? "" : "s"}.`, + ); + setOpen(false); + onDropped(); + }, + onError: (error) => toast.error(error.message), + }); + return ( + <> + + setOpen(false)} + onConfirm={(confirmation) => + drop.mutate({ confirmation, hackathonId: data.hackathon.id }) + } + pending={drop.isPending} + /> + + ); +} + +export function JudgingResetPanel({ data }: { data: ControlData }) { + const router = useRouter(); + const [action, setAction] = useState(null); + const evaluations = api.judging.dropEvaluations.useMutation(); + const projects = api.judging.resetProjects.useMutation(); + const rooms = api.judging.dropRooms.useMutation(); + const schedule = api.judging.dropSchedule.useMutation(); + const setup = api.judging.resetSetup.useMutation(); + const launch = api.judging.resetLaunch.useMutation(); + const full = api.judging.resetHackathon.useMutation(); + const mutations = { + evaluations, + full, + launch, + projects, + rooms, + schedule, + setup, + }; + const pending = action ? mutations[action].isPending : false; + + async function confirm(confirmation: string) { + if (!action) return; + try { + if (action === "schedule") { + await schedule.mutateAsync({ hackathonId: data.hackathon.id }); + } else { + await mutations[action].mutateAsync({ + confirmation, + hackathonId: data.hackathon.id, + }); + } + toast.success( + action === "full" + ? `${data.hackathon.displayName} judging is back to a clean slate.` + : `${actionCopy[action].button} complete.`, + ); + setAction(null); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Reset failed."); + } + } + + const slices: { + action: Action; + description: string; + disabled?: boolean; + label: string; + }[] = [ + { + action: "projects", + description: "Projects, team contacts, and imported challenges.", + label: "Projects", + }, + { + action: "setup", + description: "Custom groups and the saved rubric.", + label: "Challenge and rubric setup", + }, + { + action: "rooms", + description: "Rooms, links, sessions, presence, and announcements.", + disabled: data.rooms.length === 0 && !data.globalAnnouncement, + label: "Rooms and access", + }, + { + action: "schedule", + description: "Saved schedule, reservations, and unsent drafts.", + disabled: !data.hasScheduleData || data.scheduleDropLocked, + label: "Schedule", + }, + { + action: "evaluations", + description: "Submitted evaluations, drafts, answers, and revisions.", + disabled: !data.hasEvaluationData, + label: "Evaluations", + }, + { + action: "launch", + description: "Claims, publication, judging state, and result visibility.", + label: "Launch settings", + }, + ]; + + return ( +
+
+
+
+
+ {slices.map((slice) => ( +
+
+

{slice.label}

+

+ {slice.description} +

+
+ +
+ ))} +
+
+ +
+
+
+

+ Reset all judging +

+

+ Return this hackathon to its fresh judging state in one + transaction. The hackathon, global buildings, and audit history + stay in Forge. Existing Discord threads are not deleted. +

+
+ +
+
+ + !pending && setAction(null)} + onConfirm={(confirmation) => void confirm(confirmation)} + pending={pending} + /> +
+ ); +} diff --git a/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx b/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx index 68414f94c..b87e7dcbd 100644 --- a/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Clock3, RefreshCw } from "lucide-react"; +import { AlertTriangle, Clock3, RefreshCw } from "lucide-react"; import type { RouterOutputs } from "@forge/api"; import { Badge } from "@forge/ui/badge"; @@ -64,6 +64,7 @@ export function JudgingSchedulePanel({ string | null >(null); const [dropping, setDropping] = useState(false); + const [dismissedAlert, setDismissedAlert] = useState(null); const [selection, setSelection] = useState(null); const [inspecting, setInspecting] = useState(null); const refresh = () => @@ -191,18 +192,29 @@ export function JudgingSchedulePanel({ : job?.status === "incomplete" ? "Search ended without a complete schedule" : "Preview"; + const failedJob = + !data.schedule && + job && + (job.status === "infeasible" || job.status === "incomplete") + ? job + : null; + const alertKey = query.error + ? `refresh:${query.error.message}` + : failedJob + ? `job:${failedJob.id}:${failedJob.status}` + : null; + const diagnostics = (failedJob?.diagnostics ?? []).map((message) => + data.source.tasks.reduce( + (copy, task) => + copy + .replaceAll(task.challengeId, task.challengeLabel) + .replaceAll(task.projectId, task.title), + message, + ), + ); return (
- {query.error ? ( -

- Refresh failed: {query.error.message}. The schedule shown may be out - of date. -

- ) : null} {!data.schedule ? (

- {data.schedule ? "Judging schedule" : generationLabel} + {data.schedule + ? "Judging schedule" + : failedJob + ? "Schedule preview" + : generationLabel}

{timing @@ -303,7 +319,9 @@ export function JudgingSchedulePanel({

) : null} - {!data.schedule && job ? ( + {!data.schedule && + job && + (job.status === "searching" || job.status === "feasible") ? (
{job.status === "searching" ? (

@@ -318,33 +336,6 @@ export function JudgingSchedulePanel({ before optimality could be proven.

) : null} - {job.status === "incomplete" ? ( -

- No complete candidate was found within the limit. This does not - prove the window is impossible, so travel breaks have not been - silently relaxed. -

- ) : null} - {job.diagnostics.map((message) => ( -

- {data.source.tasks.reduce( - (copy, task) => - copy - .replaceAll(task.challengeId, task.challengeLabel) - .replaceAll(task.projectId, task.title), - message, - )} -

- ))} - {job.status === "infeasible" && !job.diagnostics.length ? ( -

- Neither the full travel break nor the baseline break can fit every - presentation. Adjust the window, timing, or staffed rooms. -

- ) : null}
) : null} {!data.schedule && job?.reducedBreaks.length ? ( @@ -557,6 +548,57 @@ export function JudgingSchedulePanel({ } timeZone={timeZone} /> + { + if (!open) setDismissedAlert(alertKey); + }} + > + + +
+
+ + {query.error ? "Schedule refresh failed" : generationLabel} + + + {query.error + ? `${query.error.message}. The schedule shown may be out of date.` + : failedJob?.status === "incomplete" + ? "No complete candidate was found within the limit. This does not prove the window is impossible, and travel breaks were not silently relaxed." + : "The current window and room configuration cannot fit every presentation."} + +
+ {!query.error ? ( + diagnostics.length ? ( +
    + {diagnostics.map((message) => ( +
  • + {message} +
  • + ))} +
+ ) : ( +

+ Adjust the judging window, appointment timing, or available + rooms, then generate another preview. +

+ ) + ) : null} + + + +
+
diff --git a/apps/blade/src/app/_components/judging/project-command-center.tsx b/apps/blade/src/app/_components/judging/project-command-center.tsx index 4364f037b..94c4a90df 100644 --- a/apps/blade/src/app/_components/judging/project-command-center.tsx +++ b/apps/blade/src/app/_components/judging/project-command-center.tsx @@ -15,8 +15,16 @@ import { adminPageLayoutClassName, } from "../shared/admin-page"; import { EvaluationAuditPanel } from "./evaluation-audit-panel"; -import { JudgingConfigurationPanel } from "./judging-configuration-panel"; +import { + JudgingConfigurationPanel, + JudgingLaunchControls, +} from "./judging-configuration-panel"; import { JudgingControlPanel } from "./judging-control-panel"; +import { JudgingLaunchChecklist } from "./judging-launch-checklist"; +import { + DropEvaluationsButton, + JudgingResetPanel, +} from "./judging-reset-panel"; import { JudgingSchedulePanel } from "./judging-schedule-panel"; import { ProjectClaimsPanel } from "./project-claims-panel"; @@ -43,9 +51,10 @@ export function ProjectCommandCenter({ hackathonId: string; }; selectedTab: - | "claims" | "evaluations" + | "launch" | "projects" + | "reset" | "rooms" | "setup" | "schedule"; @@ -58,13 +67,16 @@ export function ProjectCommandCenter({ const [activeTab, setActiveTab] = useOptimistic(selectedTab); - function selectTab(tab: string) { + function selectTab(tab: string, section?: string) { const next = new URLSearchParams(searchParams.toString()); - if (tab === "setup") next.delete("tab"); + if (tab === "projects") next.delete("tab"); else next.set("tab", tab); startTransition(() => { setActiveTab(tab); - router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + router.replace( + `${pathname}?${next.toString()}${section ? `#${section}` : ""}`, + { scroll: section !== undefined }, + ); }); } @@ -80,44 +92,52 @@ export function ProjectCommandCenter({
- - Hackathon - - - +
+ + +
} description="Configure the rubric, manage the Devpost inventory, provision rooms, and control what judges can see." eyebrow="Officer tools" icon={ClipboardList} title="Project command center" /> - - - Setup - Projects - Rooms - Schedule - Hacker access - Evaluations - - - - + selectTab(tab)} value={activeTab}> +
+

+ Setup flow +

+ + 1 · Projects + 2 · Rubric + 3 · Rooms + 4 · Schedule + 5 · Launch + Evaluations + + Reset + + +
- +
+ +
- +
+ +
+
+ +
+ +
+ +
+
+
+ router.refresh()} + /> +
- {scheduleData ? ( - - ) : null} +
+ {scheduleData ? ( + + ) : null} +
+
+ +
diff --git a/apps/blade/src/app/_components/projects/admin-project-workspace.tsx b/apps/blade/src/app/_components/projects/admin-project-workspace.tsx index bf581b85f..cb1049855 100644 --- a/apps/blade/src/app/_components/projects/admin-project-workspace.tsx +++ b/apps/blade/src/app/_components/projects/admin-project-workspace.tsx @@ -377,80 +377,61 @@ export function AdminProjectWorkspace({ icon={Database} title="Project import" /> - ) : data && input ? ( -
- hackathon.id === input.hackathonId) - ?.inventoryLockedAt != null - } - onImported={refresh} - projectCount={projectCount} - /> - -
) : null} {data && input ? ( <> -
- {!embedded ? ( - - ) : ( -
-

Project inventory

-

- {data.hackathon.displayName} · {projectCount} imported -

+ {!embedded ? ( + <> +
+ + +
+
+ + {data.totalCount} in this view + + + {data.challenges.length} challenges +
- )} - -
- -
- {data.totalCount} in this view - {data.challenges.length} challenges -
+ + ) : null} ( @@ -488,6 +469,55 @@ export function AdminProjectWorkspace({ data={data} input={input} showPrivateDetails + toolbar={ + embedded ? ( +
+

+ + {data.totalCount} in this view + {" "} + · {data.challenges.length} challenge + {data.challenges.length === 1 ? "" : "s"} +

+
+ + hackathon.id === input.hackathonId, + )?.inventoryLockedAt != null + } + onImported={refresh} + projectCount={projectCount} + /> + +
+
+ ) : undefined + } /> + {toolbar ? ( +
{toolbar}
+ ) : null}
{ @@ -591,6 +596,7 @@ export function ProjectDirectory({ showRatingSort = false, showTeamSizeFilters = true, showViewAction = false, + toolbar, }: { actions?: (project: TProject) => React.ReactNode; data: ProjectDirectoryData; @@ -612,6 +618,7 @@ export function ProjectDirectory({ showRatingSort?: boolean; showTeamSizeFilters?: boolean; showViewAction?: boolean; + toolbar?: React.ReactNode; }) { const pathname = usePathname(); const router = useRouter(); @@ -647,6 +654,7 @@ export function ProjectDirectory({ roomFilterUnavailableReason={roomFilterUnavailableReason} showRatingSort={showRatingSort} showTeamSizeFilters={showTeamSizeFilters} + toolbar={toolbar} /> ({ + confirmDelete: vi.fn(), + confirmStatus: vi.fn(), + previewDelete: vi.fn(), + previewStatus: vi.fn(), +})); + +vi.mock("~/trpc/react", () => ({ + api: { + hacker: { + confirmBulk: { + useMutation: () => ({ + isPending: false, + mutate: mutations.confirmStatus, + }), + }, + confirmBulkDelete: { + useMutation: () => ({ + isPending: false, + mutate: mutations.confirmDelete, + }), + }, + previewBulk: { + useMutation: () => ({ + data: undefined, + error: null, + isPending: false, + mutate: mutations.previewStatus, + reset: vi.fn(), + }), + }, + previewBulkDelete: { + useMutation: () => ({ + data: { + deleting: [ + { + attendeeId: "attendee-1", + email: "hacker@example.test", + name: "Test Hacker", + }, + ], + skipped: [], + }, + error: null, + isPending: false, + mutate: mutations.previewDelete, + reset: vi.fn(), + }), + }, + }, + }, +})); + +beforeEach(() => vi.clearAllMocks()); + +describe("bulk hacker deletion", () => { + it("states that deletion is permanent and sends no email", () => { + const copy = bulkActionCopy("delete", 3); + + expect(copy.confirm).toBe("Delete 3 applications"); + expect(copy.description).toMatch(/permanently.*No email is sent/); + expect(copy.label).toBe("Will be permanently deleted"); + }); + + it("routes delete preview and confirmation through delete-only mutations", async () => { + const user = userEvent.setup(); + render( + createElement(BulkConfirmDialog, { + action: "delete", + attendeeIds: ["attendee-1"], + hackathonId: "hackathon-1", + onDone: vi.fn(), + onOpenChange: vi.fn(), + }), + ); + + expect(mutations.previewDelete).toHaveBeenCalledWith({ + attendeeIds: ["attendee-1"], + hackathonId: "hackathon-1", + }); + expect(mutations.previewStatus).not.toHaveBeenCalled(); + + await user.click( + screen.getByRole("button", { name: "Delete 1 applications" }), + ); + expect(mutations.confirmDelete).toHaveBeenCalledWith({ + attendeeIds: ["attendee-1"], + confirmed: true, + hackathonId: "hackathon-1", + }); + expect(mutations.confirmStatus).not.toHaveBeenCalled(); + }); +}); describe("bulk hacker status skip labels", () => { it("explains that checked-in admission is permanent", () => { diff --git a/apps/blade/src/tests/admin/hacker-detail-dialog.test.tsx b/apps/blade/src/tests/admin/hacker-detail-dialog.test.tsx index 9ca432b53..d833b44c4 100644 --- a/apps/blade/src/tests/admin/hacker-detail-dialog.test.tsx +++ b/apps/blade/src/tests/admin/hacker-detail-dialog.test.tsx @@ -1,11 +1,15 @@ +/** @vitest-environment jsdom */ import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { HackerDetailDialog } from "~/app/_components/admin/hackathon/hackers/hacker-detail-dialog"; const fixture = vi.hoisted(() => ({ data: { + attendeeId: "test-attendee", firstName: "Test", lastName: "Hacker", name: "Test Hacker", @@ -17,6 +21,7 @@ const fixture = vi.hoisted(() => ({ survey1: "", survey2: "", }, + deleteApplication: vi.fn(), })); vi.mock("@forge/ui/dialog", () => { @@ -48,6 +53,12 @@ vi.mock("~/trpc/react", () => { api: { hacker: { get: { useQuery: () => ({ data: fixture.data }) }, + deleteApplication: { + useMutation: () => ({ + isPending: false, + mutate: fixture.deleteApplication, + }), + }, setStatus: mutation, setBlacklist: mutation, awardPoints: mutation, @@ -57,10 +68,12 @@ vi.mock("~/trpc/react", () => { }; }); -function renderDetail() { +beforeEach(() => vi.clearAllMocks()); + +function renderDetail(canEdit = false) { return renderToStaticMarkup( { expect(renderDetail().match(/No response provided\./g)).toHaveLength(2); }); }); + +describe("Hacker application deletion", () => { + it("keeps the individual delete action in the editable detail view", () => { + const html = renderDetail(true); + expect(html).toContain("Delete application"); + expect(html).toContain("permanently deletes this application"); + }); + + it("requires confirmation and deletes the selected attendee", async () => { + const user = userEvent.setup(); + render( + , + ); + + const confirm = screen.getAllByRole("button", { + name: "Delete application", + })[1]; + if (!confirm) throw new Error("Delete confirmation was not rendered"); + expect(confirm).toBeDisabled(); + await user.click( + screen.getByRole("checkbox", { + name: /I understand this permanently deletes this application/, + }), + ); + await user.click(confirm); + + expect(fixture.deleteApplication).toHaveBeenCalledWith({ + attendeeId: "test-attendee", + confirmed: true, + }); + }); +}); diff --git a/apps/blade/src/tests/admin/hacker-filter-facets.test.ts b/apps/blade/src/tests/admin/hacker-filter-facets.test.ts index ff613601c..c83696929 100644 --- a/apps/blade/src/tests/admin/hacker-filter-facets.test.ts +++ b/apps/blade/src/tests/admin/hacker-filter-facets.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; -import { changedFacets } from "~/app/_components/admin/hackathon/hackers/hacker-filters"; +import { + changedFacets, + HACKER_STATUS_FILTERS, +} from "~/app/_components/admin/hackathon/hackers/hacker-filters"; + +describe("hacker status filters", () => { + it("includes checked-in applicants", () => { + expect(HACKER_STATUS_FILTERS).toContain("checkedin"); + }); +}); /** * What the Filters panel sends when Apply is pressed. diff --git a/apps/blade/src/tests/admin/hacker-roster-actions.test.tsx b/apps/blade/src/tests/admin/hacker-roster-actions.test.tsx new file mode 100644 index 000000000..43c1f5f8e --- /dev/null +++ b/apps/blade/src/tests/admin/hacker-roster-actions.test.tsx @@ -0,0 +1,143 @@ +/** @vitest-environment jsdom */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { RouterOutputs } from "@forge/api"; + +import { HackerRoster } from "~/app/_components/admin/hackathon/hackers/hacker-roster"; + +const selection = vi.hoisted(() => ({ + clear: vi.fn(), + deselect: vi.fn(), + resetAnchor: vi.fn(), + selectRange: vi.fn(), + selected: new Set(["attendee-1"]), + setAllShown: vi.fn(), + toggle: vi.fn(), +})); + +vi.mock( + "~/app/_components/admin/hackathon/hackers/bulk-confirm-dialog", + () => ({ + BulkConfirmDialog: () => null, + }), +); +vi.mock( + "~/app/_components/admin/hackathon/hackers/filter-change-dialog", + () => ({ + FilterChangeDialog: () => null, + }), +); +vi.mock( + "~/app/_components/admin/hackathon/hackers/hacker-detail-dialog", + () => ({ + HackerDetailDialog: () => null, + }), +); +vi.mock("~/app/_components/admin/hackathon/hackers/hacker-filters", () => ({ + FilterChips: () => null, + HackerFilters: () => null, + StatusTabs: () => null, +})); +vi.mock("~/app/_components/admin/hackathon/hackers/hacker-table", () => ({ + HackerTable: () => null, +})); +vi.mock("~/app/_components/admin/hackathon/hackers/use-filter-flow", () => ({ + useFilterFlow: () => ({ + busy: false, + cancelPrompt: vi.fn(), + flow: { kind: "idle" }, + proceedWithPrompt: vi.fn(), + requestFilter: vi.fn(), + search: "", + setSearch: vi.fn(), + }), +})); +vi.mock( + "~/app/_components/admin/hackathon/hackers/use-hacker-selection", + () => ({ + useHackerSelection: () => selection, + }), +); +vi.mock( + "~/app/_components/admin/hackathon/hackers/use-roster-url-state", + () => ({ + useRosterUrlState: () => ({ + filter: {}, + hackerId: null, + navigating: false, + projectFilter: vi.fn(), + setFilter: vi.fn(), + setHackathonId: vi.fn(), + setHackerId: vi.fn(), + setShowAll: vi.fn(), + showAll: false, + wouldMove: vi.fn(), + }), + }), +); +vi.mock("~/trpc/react", () => ({ + api: { + hacker: { + filterOptions: { + useQuery: () => ({ data: undefined, isError: false, isPending: false }), + }, + listForHackathon: { + useQuery: () => ({ + data: { hackers: [], nextCursor: null }, + error: null, + isError: false, + isFetching: false, + isPending: false, + }), + }, + statusCounts: { + useQuery: () => ({ + data: { byStatus: {}, total: 0 }, + error: null, + isError: false, + isFetching: false, + isPending: false, + }), + }, + }, + useUtils: () => ({ + hacker: { + invalidate: vi.fn(), + selectionSurvival: { fetch: vi.fn() }, + }, + }), + }, +})); + +type Hackathon = + RouterOutputs["hacker"]["listHackathonOptions"]["hackathons"][number]; + +const hackathon = { + displayName: "Knight Hacks", + endDate: new Date("2026-10-05T00:00:00.000Z"), + hasEnded: false, + id: "hackathon-1", + startDate: new Date("2026-10-03T00:00:00.000Z"), +} satisfies Hackathon; + +describe("hacker roster bulk actions", () => { + it("places Delete after a separate Actions label", () => { + render( + , + ); + + const moveTo = screen.getByText("Move to"); + const actions = screen.getByText("Actions"); + const remove = screen.getByRole("button", { name: "Delete" }); + + expect(actions).not.toBe(moveTo); + expect(actions.parentElement).toBe(moveTo.parentElement); + expect(actions.nextElementSibling).toBe(remove); + }); +}); diff --git a/apps/blade/src/tests/projects/challenge-configuration-panel.test.tsx b/apps/blade/src/tests/projects/challenge-configuration-panel.test.tsx index 48c4c5cd1..bb902d19f 100644 --- a/apps/blade/src/tests/projects/challenge-configuration-panel.test.tsx +++ b/apps/blade/src/tests/projects/challenge-configuration-panel.test.tsx @@ -87,14 +87,20 @@ const data = { ], setupLocked: false, challengeSetupLocked: false, + hasEvaluationData: false, + hasSavedSchedule: false, + hasScheduleData: false, + scheduleDropLocked: false, configuration: { closedAt: null, displayAllResults: false, + hackerSchedulePublished: false, judgingCommsChannelId: null, openedAt: null, state: "draft", }, inventoryLockedAt: null, + inventory: { claimLinksSent: false, memberCount: 0, projectCount: 0 }, discordGuildId: null, globalAnnouncement: null, rubric: [], diff --git a/apps/blade/src/tests/projects/judging-announcement-editor.test.tsx b/apps/blade/src/tests/projects/judging-announcement-editor.test.tsx index 234c9c888..10fb4e8bf 100644 --- a/apps/blade/src/tests/projects/judging-announcement-editor.test.tsx +++ b/apps/blade/src/tests/projects/judging-announcement-editor.test.tsx @@ -9,6 +9,7 @@ import { AnnouncementDialog, JudgingControlPanel, } from "~/app/_components/judging/judging-control-panel"; +import { JudgingLaunchChecklist } from "~/app/_components/judging/judging-launch-checklist"; const mutations = vi.hoisted(() => ({ adminData: undefined as unknown, @@ -43,6 +44,7 @@ vi.mock("~/trpc/react", () => { api: { judging: { archiveRoom: { useMutation: idleMutation }, + deleteRoom: { useMutation: idleMutation }, clearAnnouncement: { useMutation: () => ({ isPending: false, @@ -123,6 +125,10 @@ type Announcement = NonNullable; const data = { setupLocked: false, challengeSetupLocked: false, + hasEvaluationData: false, + hasSavedSchedule: false, + hasScheduleData: false, + scheduleDropLocked: false, hackathon: { id: "00000000-0000-4000-8000-000000000001", displayName: "Knight Hacks", @@ -131,6 +137,7 @@ const data = { configuration: { closedAt: null, displayAllResults: false, + hackerSchedulePublished: false, judgingCommsChannelId: null, openedAt: null, state: "draft", @@ -139,6 +146,7 @@ const data = { discordGuildId: null, globalAnnouncement: null, inventoryLockedAt: null, + inventory: { claimLinksSent: false, memberCount: 0, projectCount: 0 }, rooms: [], rubric: [], } satisfies ControlData; @@ -308,6 +316,10 @@ describe("judging room and announcement editors", () => { const initialData = { setupLocked: false, challengeSetupLocked: false, + hasEvaluationData: false, + hasSavedSchedule: false, + hasScheduleData: false, + scheduleDropLocked: false, challenges: [ { id: room.challengeId, @@ -322,6 +334,7 @@ describe("judging room and announcement editors", () => { configuration: { closedAt: null, displayAllResults: false, + hackerSchedulePublished: false, judgingCommsChannelId: null, openedAt: null, state: "draft", @@ -334,6 +347,7 @@ describe("judging room and announcement editors", () => { timezone: "America/New_York", }, inventoryLockedAt: null, + inventory: { claimLinksSent: false, memberCount: 0, projectCount: 0 }, rooms: [room], rubric: [], } as ControlData; @@ -343,7 +357,7 @@ describe("judging room and announcement editors", () => { ); expect( - screen.getByText(/A saved schedule locks room changes/), + screen.getByText(/A saved schedule locks room layout changes/), ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Create room" })).toBeDisabled(); @@ -378,3 +392,74 @@ describe("judging room and announcement editors", () => { ).toBeChecked(); }); }); + +describe("judging launch checklist", () => { + it("counts readiness and navigates to the owning section", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const view = render( + , + ); + + expect( + screen.getByRole("button", { name: /Launch checklist.*0\/8/ }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Launch checklist/ })); + await user.click( + screen.getByRole("button", { name: /1\. Import projects/ }), + ); + expect(onNavigate).toHaveBeenCalledWith("projects", "project-inventory"); + + const ready = { + ...data, + challenges: [ + { + id: "00000000-0000-4000-8000-000000000010", + isGeneral: true, + isGroup: true, + isMlhImportDefault: false, + isScheduled: true, + label: "General", + parentId: null, + }, + ], + configuration: { ...data.configuration, state: "open" as const }, + hasSavedSchedule: true, + hasScheduleData: true, + scheduleDropLocked: false, + inventory: { claimLinksSent: true, memberCount: 1, projectCount: 1 }, + rooms: [ + { + activeLinkId: "00000000-0000-4000-8000-000000000011", + announcement: null, + archivedAt: null, + buildingId: "00000000-0000-4000-8000-000000000012", + buildingName: "Engineering", + challengeId: "00000000-0000-4000-8000-000000000010", + challengeLabel: "General", + discordThreadId: null, + id: "00000000-0000-4000-8000-000000000013", + judges: [], + name: "101", + }, + ], + rubric: [ + { + description: "", + guestVisibilityPolicy: null, + id: "00000000-0000-4000-8000-000000000014", + kind: "rating" as const, + label: "Quality", + memberVisibilityPolicy: null, + required: true, + }, + ], + } satisfies ControlData; + view.rerender( + , + ); + expect( + screen.getByRole("button", { name: /Launch checklist.*8\/8/ }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/blade/src/tests/projects/judging-reset-panel.test.tsx b/apps/blade/src/tests/projects/judging-reset-panel.test.tsx new file mode 100644 index 000000000..7923b6c2e --- /dev/null +++ b/apps/blade/src/tests/projects/judging-reset-panel.test.tsx @@ -0,0 +1,160 @@ +/** @vitest-environment jsdom */ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, test, vi } from "vitest"; + +import type { RouterOutputs } from "@forge/api"; + +import { JudgingResetPanel } from "~/app/_components/judging/judging-reset-panel"; + +const mutations = vi.hoisted(() => ({ + dropEvaluations: vi.fn().mockResolvedValue({}), + dropRooms: vi.fn().mockResolvedValue({}), + dropSchedule: vi.fn().mockResolvedValue({}), + refresh: vi.fn(), + resetHackathon: vi.fn().mockResolvedValue({}), + resetLaunch: vi.fn().mockResolvedValue({}), + resetProjects: vi.fn().mockResolvedValue({}), + resetSetup: vi.fn().mockResolvedValue({}), +})); + +vi.mock("~/app/_components/shared/route-transition-link", () => ({ + useNavigationRouter: () => ({ refresh: mutations.refresh }), +})); +vi.mock("~/trpc/react", () => ({ + api: { + judging: { + dropEvaluations: { + useMutation: () => mutation(mutations.dropEvaluations), + }, + dropRooms: { useMutation: () => mutation(mutations.dropRooms) }, + dropSchedule: { useMutation: () => mutation(mutations.dropSchedule) }, + resetHackathon: { + useMutation: () => mutation(mutations.resetHackathon), + }, + resetLaunch: { useMutation: () => mutation(mutations.resetLaunch) }, + resetProjects: { useMutation: () => mutation(mutations.resetProjects) }, + resetSetup: { useMutation: () => mutation(mutations.resetSetup) }, + }, + }, +})); + +function mutation(mutateAsync: ReturnType) { + return { isPending: false, mutateAsync }; +} + +type ControlData = RouterOutputs["judging"]["listAdmin"]; + +const data = { + challengeSetupLocked: false, + challenges: [], + configuration: { + closedAt: null, + displayAllResults: false, + hackerSchedulePublished: false, + judgingCommsChannelId: null, + openedAt: null, + state: "draft", + }, + discordGuildId: null, + globalAnnouncement: { + id: "announcement-1", + includeGuests: false, + isUrgent: false, + message: "Testing", + publishedAt: new Date("2026-09-01T00:00:00.000Z"), + roomId: null, + }, + hackathon: { + displayName: "Knight Hacks", + id: "hackathon-1", + timezone: "America/New_York", + }, + hasEvaluationData: true, + hasSavedSchedule: false, + hasScheduleData: true, + inventory: { claimLinksSent: false, memberCount: 0, projectCount: 0 }, + inventoryLockedAt: null, + rooms: [], + rubric: [], + scheduleDropLocked: false, + setupLocked: false, +} satisfies ControlData; + +const cases = [ + ["Projects", "Reset projects", mutations.resetProjects], + [ + "Challenge and rubric setup", + "Reset challenge and rubric setup", + mutations.resetSetup, + ], + ["Rooms and access", "Drop rooms and access", mutations.dropRooms], + ["Schedule", "Reset schedule", mutations.dropSchedule], + ["Evaluations", "Drop evaluations", mutations.dropEvaluations], + ["Launch settings", "Reset launch settings", mutations.resetLaunch], +] as const; + +beforeEach(() => vi.clearAllMocks()); + +function granularResetButton(label: string) { + const heading = screen.getByRole("heading", { name: label }); + const card = heading.parentElement?.parentElement; + if (!card) throw new Error(`Reset card for ${label} was not rendered`); + return within(card).getByRole("button", { name: "Reset" }); +} + +async function confirmReset(button: HTMLElement, confirmLabel: string) { + const user = userEvent.setup(); + await user.click(button); + await user.type( + screen.getByRole("textbox", { name: "Hackathon name" }), + "Knight Hacks", + ); + await user.click(screen.getByRole("button", { name: confirmLabel })); +} + +describe("judging reset actions", () => { + test.each(cases)( + "%s routes to its dedicated mutation", + async (label, confirmLabel, expectedMutation) => { + render(); + + await confirmReset(granularResetButton(label), confirmLabel); + + expect(expectedMutation).toHaveBeenCalledWith( + label === "Schedule" + ? { hackathonId: "hackathon-1" } + : { confirmation: "Knight Hacks", hackathonId: "hackathon-1" }, + ); + expect( + Object.values(mutations) + .filter((candidate) => candidate !== mutations.refresh) + .reduce((total, candidate) => total + candidate.mock.calls.length, 0), + ).toBe(1); + }, + ); + + it("routes the full reset to resetHackathon", async () => { + render(); + + await confirmReset( + screen.getByRole("button", { name: "Reset all judging" }), + "Reset all judging", + ); + + expect(mutations.resetHackathon).toHaveBeenCalledWith({ + confirmation: "Knight Hacks", + hackathonId: "hackathon-1", + }); + }); + + it("offers job-only schedule cleanup but disables a locked schedule", () => { + const view = render(); + expect(granularResetButton("Schedule")).toBeEnabled(); + + view.rerender( + , + ); + expect(granularResetButton("Schedule")).toBeDisabled(); + }); +}); diff --git a/packages/api/src/routers/hacker.ts b/packages/api/src/routers/hacker.ts index 75ff41353..ef7e43c5e 100644 --- a/packages/api/src/routers/hacker.ts +++ b/packages/api/src/routers/hacker.ts @@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import type { SQL } from "@forge/db"; -import type { HackerRosterFilter, SkipReason } from "@forge/validators"; +import type { + HackerBulkStatus, + HackerRosterFilter, + SkipReason, +} from "@forge/validators"; import { and, count, @@ -27,6 +31,8 @@ import { import { HACKATHON_SENDING_STATUSES, hackerAwardPointsSchema, + hackerBulkDeleteConfirmSchema, + hackerBulkDeletePreviewSchema, hackerBulkPreviewSchema, hackerDeleteApplicationSchema, hackerFilterOptionsSchema, @@ -49,6 +55,7 @@ import { getDiscordEngagement } from "../utils/discord/engagement"; import { redactHackerBlacklist, redactHackerSkipReasons, + requireHackerBulkEdit, requireHackerEdit, requireHackerRead, } from "../utils/hacker/access"; @@ -376,6 +383,21 @@ interface BulkSkip { reason: SkipReason; } +interface ApplicationDeleteTarget { + attendeeId: string; + blacklistedAt: Date | null; + firstName: string; + hackerId: string; + hackathonId: string; + lastName: string; + userId: string; +} + +interface BulkApplicationDeleteTarget extends ApplicationDeleteTarget { + email: string; + name: string; +} + export const hackerRouter = createTRPCRouter({ /** * Hacker read access. The hackathons the roster's picker offers. @@ -806,22 +828,24 @@ export const hackerRouter = createTRPCRouter({ previewBulk: permProcedure .input(hackerBulkPreviewSchema) .mutation(async ({ ctx, input }) => { - requireHackerEdit(ctx); + requireHackerBulkEdit(ctx, input.status); const hackathon = await requireHackathon(input.hackathonId); assertHackathonNotEnded(hackathon); - await assertHackathonReady(db, input.hackathonId); - - // Runs the *whole* preparation and throws the result away. - // - // Checking the gates individually was not enough: `assertHackathonReady` - // only counts configured rows, while `prepareStatusMail` also resolves - // the template and its published revision. A hackathon with all six - // statuses set but an archived template, or one whose template has no - // published version, previewed "Send 300 emails" and then died on - // confirm — the exact failure the preview exists to prevent. Compiling - // twice costs one template render; being wrong costs an officer's - // confidence in the preview. - await prepareStatusMail({ hackathon, status: input.status }); + if (input.status !== "checkedin") { + await assertHackathonReady(db, input.hackathonId); + + // Runs the *whole* preparation and throws the result away. + // + // Checking the gates individually was not enough: `assertHackathonReady` + // only counts configured rows, while `prepareStatusMail` also resolves + // the template and its published revision. A hackathon with all six + // statuses set but an archived template, or one whose template has no + // published version, previewed "Send 300 emails" and then died on + // confirm — the exact failure the preview exists to prevent. Compiling + // twice costs one template render; being wrong costs an officer's + // confidence in the preview. + await prepareStatusMail({ hackathon, status: input.status }); + } const { sending, skipped } = await resolveBulkTargets(db, input); @@ -855,17 +879,20 @@ export const hackerRouter = createTRPCRouter({ confirmBulk: permProcedure .input(hackerBulkPreviewSchema) .mutation(async ({ ctx, input }) => { - requireHackerEdit(ctx); + requireHackerBulkEdit(ctx, input.status); const hackathon = await requireHackathon(input.hackathonId); const auditActor = await captureAdminAuditActor(ctx.session.user); // Read and compiled before the transaction, for the pool reason above. assertHackathonNotEnded(hackathon); - await assertHackathonReady(db, input.hackathonId); - const prepared = await prepareStatusMail({ - hackathon, - status: input.status, - }); + let prepared: Awaited> | null = null; + if (input.status !== "checkedin") { + await assertHackathonReady(db, input.hackathonId); + prepared = await prepareStatusMail({ + hackathon, + status: input.status, + }); + } return db.transaction(async (tx) => { const { sending, skipped } = await resolveBulkTargets(tx, input, true); @@ -879,16 +906,32 @@ export const hackerRouter = createTRPCRouter({ }; } - const sendId = await writeStatusMail( - tx, - prepared, - ctx.session.user.id, - sending, - ); + const sendId = prepared + ? await writeStatusMail( + tx, + prepared, + ctx.session.user.id, + sending.map((row) => ({ ...row, status: prepared.status })), + ) + : null; + const withheldCount = prepared + ? withheldByDevelopmentGate( + prepared.teamUserIds, + sending.map((row) => ({ ...row, status: prepared.status })), + ) + : 0; await tx .update(HackerAttendee) - .set({ lastStatusSendId: sendId, status: input.status }) + .set( + input.status === "checkedin" + ? { + checkedInAt: new Date(), + checkedInBy: ctx.session.user.id, + status: input.status, + } + : { lastStatusSendId: sendId, status: input.status }, + ) .where( inArray( HackerAttendee.id, @@ -922,10 +965,7 @@ export const hackerRouter = createTRPCRouter({ // Stated when the subject list below is partial, so nobody reads // a truncated list as the whole bulk. subjectsTruncated: sending.length > BULK_AUDIT_SUBJECT_LIMIT, - withheldCount: withheldByDevelopmentGate( - prepared.teamUserIds, - sending, - ), + withheldCount, }, subjects: bulkAuditSubjects( { displayName: hackathon.displayName, id: input.hackathonId }, @@ -942,10 +982,79 @@ export const hackerRouter = createTRPCRouter({ // Surfaced so the officer is told when a development run mails fewer // people than it moved, instead of reporting a clean success and // sending nothing. - withheldCount: withheldByDevelopmentGate( - prepared.teamUserIds, - sending, - ), + withheldCount, + }; + }); + }), + + /** Hacker edit access. Preview a permanent bulk application deletion. */ + previewBulkDelete: permProcedure + .input(hackerBulkDeletePreviewSchema) + .mutation(async ({ ctx, input }) => { + requireHackerEdit(ctx); + await requireHackathon(input.hackathonId); + + const { deleting, skipped } = await resolveBulkDeleteTargets( + db, + input, + ctx.session.permissions.IS_OFFICER === true, + ); + return { + deleting: deleting.map((row) => ({ + attendeeId: row.attendeeId, + email: row.email, + name: row.name, + })), + skipped: redactHackerSkipReasons(skipped, ctx), + }; + }), + + /** Hacker edit access. Permanently delete the selected applications. */ + confirmBulkDelete: permProcedure + .input(hackerBulkDeleteConfirmSchema) + .mutation(async ({ ctx, input }) => { + requireHackerEdit(ctx); + const hackathon = await requireHackathon(input.hackathonId); + const auditActor = await captureAdminAuditActor(ctx.session.user); + + return db.transaction(async (tx) => { + const { deleting, skipped } = await resolveBulkDeleteTargets( + tx, + input, + ctx.session.permissions.IS_OFFICER === true, + true, + ); + if (deleting.length === 0) { + return { + deletedCount: 0, + skipped: redactHackerSkipReasons(skipped, ctx), + }; + } + + const deletion = await deleteApplications(tx, deleting); + await createAdminAuditEvent( + { + actionKey: "hacker.bulk_applications_deleted", + actor: auditActor, + metadata: { + clearedCommandCount: deletion.clearedCommandCount, + deletedCount: deleting.length, + hackathonId: input.hackathonId, + legacySnapshotDeletedCount: deletion.legacySnapshotDeletedCount, + skippedCount: skipped.length, + subjectsTruncated: deleting.length > BULK_AUDIT_SUBJECT_LIMIT, + }, + subjects: bulkAuditSubjects( + { displayName: hackathon.displayName, id: input.hackathonId }, + deleting, + ), + }, + tx, + ); + + return { + deletedCount: deleting.length, + skipped: redactHackerSkipReasons(skipped, ctx), }; }); }), @@ -1186,6 +1295,7 @@ export const hackerRouter = createTRPCRouter({ return db.transaction(async (tx) => { const [application] = await tx .select({ + attendeeId: HackerAttendee.id, blacklistedAt: HackerAttendee.blacklistedAt, firstName: Hacker.firstName, hackerId: HackerAttendee.hackerId, @@ -1214,36 +1324,15 @@ export const hackerRouter = createTRPCRouter({ }); } - const clearedCommands = await tx - .delete(HackerParticipantCommand) - .where( - and( - eq(HackerParticipantCommand.userId, application.userId), - eq(HackerParticipantCommand.hackathonId, application.hackathonId), - ), - ) - .returning({ id: HackerParticipantCommand.id }); - - await tx - .delete(HackerAttendee) - .where(eq(HackerAttendee.id, input.attendeeId)); - - const [remainingReference] = await tx - .select({ id: HackerAttendee.id }) - .from(HackerAttendee) - .where(eq(HackerAttendee.hackerId, application.hackerId)) - .limit(1); - const legacySnapshotDeleted = !remainingReference; - if (legacySnapshotDeleted) { - await tx.delete(Hacker).where(eq(Hacker.id, application.hackerId)); - } + const deletion = await deleteApplications(tx, [application]); + const legacySnapshotDeleted = deletion.legacySnapshotDeletedCount === 1; await createAdminAuditEvent( { actionKey: "hacker.application_deleted", actor: auditActor, metadata: { - clearedCommandCount: clearedCommands.length, + clearedCommandCount: deletion.clearedCommandCount, hackathonId: application.hackathonId, legacySnapshotDeleted, }, @@ -1277,16 +1366,13 @@ async function resolveBulkTargets( input: { attendeeIds: string[]; hackathonId: string; - status: - | "accepted" - | "confirmed" - | "denied" - | "pending" - | "waitlisted" - | "withdrawn"; + status: HackerBulkStatus; }, lock = false, -): Promise<{ sending: StatusMailRecipient[]; skipped: BulkSkip[] }> { +): Promise<{ + sending: Omit[]; + skipped: BulkSkip[]; +}> { const base = executor .select({ attendeeId: HackerAttendee.id, @@ -1314,7 +1400,7 @@ async function resolveBulkTargets( const rows = await (lock ? base.for("update", { of: HackerAttendee }) : base); const found = new Map(rows.map((row) => [row.attendeeId, row])); - const sending: StatusMailRecipient[] = []; + const sending: Omit[] = []; const skipped: BulkSkip[] = []; /** * Addresses already claimed by an earlier applicant in this selection. @@ -1378,6 +1464,20 @@ async function resolveBulkTargets( skipped.push({ attendeeId, name, reason: "blacklisted" }); continue; } + if (input.status === "checkedin") { + if (row.status === input.status) { + skipped.push({ attendeeId, name, reason: "already" }); + continue; + } + sending.push({ + attendeeId, + email: row.email, + firstName: row.firstName, + name, + userId: row.userId, + }); + continue; + } if (!row.email.trim()) { skipped.push({ attendeeId, name, reason: "no_email" }); continue; @@ -1409,10 +1509,100 @@ async function resolveBulkTargets( email: row.email, firstName: row.firstName, name, - status: input.status, userId: row.userId, }); } return { sending, skipped }; } + +async function resolveBulkDeleteTargets( + executor: WriteDb, + input: { attendeeIds: string[]; hackathonId: string }, + isOfficer: boolean, + lock = false, +) { + const base = executor + .select({ + attendeeId: HackerAttendee.id, + blacklistedAt: HackerAttendee.blacklistedAt, + email: Hacker.email, + firstName: Hacker.firstName, + hackerId: HackerAttendee.hackerId, + hackathonId: HackerAttendee.hackathonId, + lastName: Hacker.lastName, + userId: Hacker.userId, + }) + .from(HackerAttendee) + .innerJoin(Hacker, eq(Hacker.id, HackerAttendee.hackerId)) + .where( + and( + eq(HackerAttendee.hackathonId, input.hackathonId), + inArray(HackerAttendee.id, input.attendeeIds), + ), + ); + const rows = await (lock ? base.for("update", { of: HackerAttendee }) : base); + const found = new Map(rows.map((row) => [row.attendeeId, row])); + const deleting: BulkApplicationDeleteTarget[] = []; + const skipped: BulkSkip[] = []; + + for (const attendeeId of new Set(input.attendeeIds)) { + const row = found.get(attendeeId); + if (!row) { + skipped.push({ + attendeeId, + name: "Unknown applicant", + reason: "missing", + }); + continue; + } + const name = `${row.firstName} ${row.lastName}`.trim(); + if (row.blacklistedAt && !isOfficer) { + skipped.push({ attendeeId, name, reason: "blacklisted" }); + continue; + } + deleting.push({ ...row, name }); + } + + return { deleting, skipped }; +} + +async function deleteApplications( + tx: WriteDb, + applications: ApplicationDeleteTarget[], +) { + const attendeeIds = applications.map((row) => row.attendeeId); + const hackerIds = [...new Set(applications.map((row) => row.hackerId))]; + const userIds = [...new Set(applications.map((row) => row.userId))]; + const hackathonIds = [...new Set(applications.map((row) => row.hackathonId))]; + + const clearedCommands = await tx + .delete(HackerParticipantCommand) + .where( + and( + inArray(HackerParticipantCommand.userId, userIds), + inArray(HackerParticipantCommand.hackathonId, hackathonIds), + ), + ) + .returning({ id: HackerParticipantCommand.id }); + await tx + .delete(HackerAttendee) + .where(inArray(HackerAttendee.id, attendeeIds)); + + const remaining = await tx + .selectDistinct({ hackerId: HackerAttendee.hackerId }) + .from(HackerAttendee) + .where(inArray(HackerAttendee.hackerId, hackerIds)); + const retainedHackerIds = new Set(remaining.map((row) => row.hackerId)); + const deletedHackerIds = hackerIds.filter( + (hackerId) => !retainedHackerIds.has(hackerId), + ); + if (deletedHackerIds.length > 0) { + await tx.delete(Hacker).where(inArray(Hacker.id, deletedHackerIds)); + } + + return { + clearedCommandCount: clearedCommands.length, + legacySnapshotDeletedCount: deletedHackerIds.length, + }; +} diff --git a/packages/api/src/routers/judging-schedule.ts b/packages/api/src/routers/judging-schedule.ts index 9e75573c2..2412638ef 100644 --- a/packages/api/src/routers/judging-schedule.ts +++ b/packages/api/src/routers/judging-schedule.ts @@ -533,24 +533,34 @@ export const judgingScheduleRouter = { const schedule = await tx.query.JudgingSchedule.findFirst({ where: eq(JudgingSchedule.hackathonId, input.hackathonId), }); - if (!schedule) return { dropped: false }; - if (schedule.firstResultAt) + if (schedule?.firstResultAt) throw new TRPCError({ code: "CONFLICT", message: "The first scheduled result has been submitted. This schedule can no longer be dropped.", }); - await tx - .delete(JudgingSchedule) - .where(eq(JudgingSchedule.id, schedule.id)); + if (schedule) { + await tx + .delete(JudgingSchedule) + .where(eq(JudgingSchedule.id, schedule.id)); + } + const jobs = await tx + .delete(JudgingScheduleJob) + .where(eq(JudgingScheduleJob.hackathonId, input.hackathonId)) + .returning({ id: JudgingScheduleJob.id }); + if (!schedule && jobs.length === 0) + return { dropped: false, jobCount: 0 }; await auditSchedule( tx, actor, input.hackathonId, "judging.schedule.dropped", - { scheduleId: schedule.id }, + { + jobCount: jobs.length, + ...(schedule ? { scheduleId: schedule.id } : {}), + }, ); - return { dropped: true }; + return { dropped: !!schedule, jobCount: jobs.length }; }); }), diff --git a/packages/api/src/routers/judging.ts b/packages/api/src/routers/judging.ts index 8009e1d85..c43a15474 100644 --- a/packages/api/src/routers/judging.ts +++ b/packages/api/src/routers/judging.ts @@ -22,6 +22,8 @@ import { Hackathon, HackathonJudgingConfiguration, Judge, + JudgeDeliberationEntry, + JudgeDeliberationSection, JudgingAnnouncement, JudgingBuilding, JudgingRoom, @@ -29,20 +31,28 @@ import { JudgingRoomPresence, JudgingRubricItem, JudgingSchedule, + JudgingScheduleJob, + Project, ProjectChallenge, + ProjectClaim, + ProjectClaimLink, ProjectEvaluation, ProjectEvaluationDraft, + ProjectMember, + ProjectToChallenge, } from "@forge/db/schemas/knight-hacks"; import { guestJudgeNameSchema, judgingAnnouncementClearSchema, judgingAnnouncementPublishSchema, judgingCommsChannelSchema, + judgingDestructiveActionSchema, judgingGuestSessionIdSchema, judgingHackathonIdSchema, judgingJudgeIdSchema, judgingPresenceHeartbeatSchema, judgingRoomCreateSchema, + judgingRoomDeleteSchema, judgingRoomIdSchema, judgingRoomMoveSchema, judgingRoomUpdateSchema, @@ -87,9 +97,12 @@ import { } from "../utils/member/display-name"; import { assertCanManageProjects } from "../utils/projects/access"; import { + assertChallengeSetupEditable, assertJudgingSetupEditable, challengeSelection, + rebuildParentMemberships, } from "../utils/projects/challenge-configuration"; +import { initializeJudgingGroups } from "../utils/projects/initialize-judging-groups"; import { judgingAppointmentResultsRouter } from "./judging-appointment-results"; import { judgingDraftsRouter } from "./judging-drafts"; import { judgingScheduleRouter } from "./judging-schedule"; @@ -183,6 +196,26 @@ async function writeJudgingAudit( ); } +async function lockConfirmedHackathon( + tx: WriteDb, + input: { confirmation: string; hackathonId: string }, +) { + const [hackathon] = await tx + .select({ displayName: Hackathon.displayName, id: Hackathon.id }) + .from(Hackathon) + .where(eq(Hackathon.id, input.hackathonId)) + .for("update") + .limit(1); + if (!hackathon) + throw new TRPCError({ code: "NOT_FOUND", message: "Hackathon not found." }); + if (input.confirmation !== hackathon.displayName) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The confirmation does not match the hackathon name.", + }); + return hackathon; +} + async function selectedHackathon(hackathonId?: string, allowFuture = false) { const now = new Date(); const [active] = await db @@ -1135,6 +1168,7 @@ export const judgingRouter = createTRPCRouter({ columns: { closedAt: true, displayAllResultsToMembers: true, + hackerSchedulePublished: true, judgingCommsChannelId: true, openedAt: true, projectInventoryLockedAt: true, @@ -1159,34 +1193,71 @@ export const judgingRouter = createTRPCRouter({ .where(eq(JudgingRubricItem.hackathonId, input.hackathonId)) .orderBy(asc(JudgingRubricItem.displayOrder)), ]); - const [savedSchedule, feedback, draft] = await Promise.all([ - db.query.JudgingSchedule.findFirst({ - columns: { id: true }, - where: eq(JudgingSchedule.hackathonId, input.hackathonId), - }), - db.query.ProjectEvaluation.findFirst({ - columns: { id: true }, - where: eq(ProjectEvaluation.hackathonId, input.hackathonId), - }), - db.query.ProjectEvaluationDraft.findFirst({ - columns: { id: true }, - where: eq(ProjectEvaluationDraft.hackathonId, input.hackathonId), - }), - ]); + const [savedSchedule, scheduleJob, feedback, draft, inventory] = + await Promise.all([ + db.query.JudgingSchedule.findFirst({ + columns: { firstResultAt: true, id: true }, + where: eq(JudgingSchedule.hackathonId, input.hackathonId), + }), + db.query.JudgingScheduleJob.findFirst({ + columns: { id: true }, + where: eq(JudgingScheduleJob.hackathonId, input.hackathonId), + }), + db.query.ProjectEvaluation.findFirst({ + columns: { id: true }, + where: eq(ProjectEvaluation.hackathonId, input.hackathonId), + }), + db.query.ProjectEvaluationDraft.findFirst({ + columns: { id: true }, + where: eq(ProjectEvaluationDraft.hackathonId, input.hackathonId), + }), + db + .select({ + memberCount: sql`count(${ProjectMember.id})::int`, + projectCount: sql`count(distinct ${Project.id})::int`, + sentMemberCount: sql`count(${ProjectClaimLink.sentAt})::int`, + }) + .from(Project) + .leftJoin(ProjectMember, eq(ProjectMember.projectId, Project.id)) + .leftJoin( + ProjectClaimLink, + eq(ProjectClaimLink.memberId, ProjectMember.id), + ) + .where( + and( + eq(Project.hackathonId, input.hackathonId), + isNull(Project.deletedAt), + ), + ) + .then((rows) => rows[0]), + ]); const discordGuildId = await judgingDiscordGuildId(); return { setupLocked: !!savedSchedule, challengeSetupLocked: !!savedSchedule || !!feedback || !!draft, + hasEvaluationData: !!feedback || !!draft, + hasScheduleData: !!savedSchedule || !!scheduleJob, + hasSavedSchedule: !!savedSchedule, + scheduleDropLocked: !!savedSchedule?.firstResultAt, hackathon, challenges, configuration: { closedAt: configuration?.closedAt ?? null, displayAllResults: configuration?.displayAllResultsToMembers ?? false, + hackerSchedulePublished: + configuration?.hackerSchedulePublished ?? false, judgingCommsChannelId: configuration?.judgingCommsChannelId ?? null, openedAt: configuration?.openedAt ?? null, state: configuration?.state ?? ("draft" as const), }, inventoryLockedAt: configuration?.projectInventoryLockedAt ?? null, + inventory: { + claimLinksSent: + (inventory?.memberCount ?? 0) > 0 && + (inventory?.sentMemberCount ?? 0) === (inventory?.memberCount ?? 0), + memberCount: inventory?.memberCount ?? 0, + projectCount: inventory?.projectCount ?? 0, + }, discordGuildId, globalAnnouncement: announcements.find((announcement) => announcement.roomId === null) ?? @@ -1205,6 +1276,384 @@ export const judgingRouter = createTRPCRouter({ }; }), + dropEvaluations: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + const drafts = await tx + .delete(ProjectEvaluationDraft) + .where(eq(ProjectEvaluationDraft.hackathonId, hackathon.id)) + .returning({ id: ProjectEvaluationDraft.id }); + const evaluations = await tx + .delete(ProjectEvaluation) + .where(eq(ProjectEvaluation.hackathonId, hackathon.id)) + .returning({ id: ProjectEvaluation.id }); + await tx + .update(JudgingSchedule) + .set({ firstResultAt: null }) + .where(eq(JudgingSchedule.hackathonId, hackathon.id)); + await createAdminAuditEvent( + { + actionKey: "judging.evaluations.dropped", + actor, + metadata: { + draftCount: drafts.length, + evaluationCount: evaluations.length, + }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { + draftCount: drafts.length, + evaluationCount: evaluations.length, + }; + }); + }), + + resetProjects: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + const [schedule, scheduleJob, room, evaluation, draft] = + await Promise.all([ + tx.query.JudgingSchedule.findFirst({ + columns: { id: true }, + where: eq(JudgingSchedule.hackathonId, hackathon.id), + }), + tx.query.JudgingScheduleJob.findFirst({ + columns: { id: true }, + where: eq(JudgingScheduleJob.hackathonId, hackathon.id), + }), + tx.query.JudgingRoom.findFirst({ + columns: { id: true }, + where: eq(JudgingRoom.hackathonId, hackathon.id), + }), + tx.query.ProjectEvaluation.findFirst({ + columns: { id: true }, + where: eq(ProjectEvaluation.hackathonId, hackathon.id), + }), + tx.query.ProjectEvaluationDraft.findFirst({ + columns: { id: true }, + where: eq(ProjectEvaluationDraft.hackathonId, hackathon.id), + }), + ]); + if (schedule || scheduleJob || room || evaluation || draft) + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Reset evaluations, schedule, and rooms before resetting projects.", + }); + await tx + .delete(JudgeDeliberationEntry) + .where(eq(JudgeDeliberationEntry.hackathonId, hackathon.id)); + await tx + .delete(ProjectClaim) + .where(eq(ProjectClaim.hackathonId, hackathon.id)); + const projects = await tx + .delete(Project) + .where(eq(Project.hackathonId, hackathon.id)) + .returning({ id: Project.id }); + await tx + .delete(ProjectChallenge) + .where( + and( + eq(ProjectChallenge.hackathonId, hackathon.id), + eq(ProjectChallenge.isGroup, false), + ), + ); + await tx + .update(HackathonJudgingConfiguration) + .set({ + projectClaimsStartedAt: null, + projectClaimUrl: null, + projectInventoryLockedAt: null, + projectInventoryLockedByUserId: null, + }) + .where(eq(HackathonJudgingConfiguration.hackathonId, hackathon.id)); + await createAdminAuditEvent( + { + actionKey: "project.inventory_dropped", + actor, + metadata: { projectCount: projects.length }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { projectCount: projects.length }; + }); + }), + + dropRooms: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + await assertJudgingSetupEditable(tx, hackathon.id); + await tx + .delete(JudgingAnnouncement) + .where(eq(JudgingAnnouncement.hackathonId, hackathon.id)); + const rooms = await tx + .delete(JudgingRoom) + .where(eq(JudgingRoom.hackathonId, hackathon.id)) + .returning({ id: JudgingRoom.id }); + await createAdminAuditEvent( + { + actionKey: "judging.rooms.dropped", + actor, + metadata: { roomCount: rooms.length }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { roomCount: rooms.length }; + }); + }), + + resetSetup: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + await assertChallengeSetupEditable(tx, hackathon.id); + const room = await tx.query.JudgingRoom.findFirst({ + columns: { id: true }, + where: eq(JudgingRoom.hackathonId, hackathon.id), + }); + if (room) + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Drop all room configuration before resetting setup.", + }); + await tx + .delete(ProjectToChallenge) + .where( + and( + eq(ProjectToChallenge.hackathonId, hackathon.id), + eq(ProjectToChallenge.isOptIn, false), + ), + ); + await tx + .update(ProjectChallenge) + .set({ isScheduled: true, parentId: null }) + .where( + and( + eq(ProjectChallenge.hackathonId, hackathon.id), + eq(ProjectChallenge.isGroup, false), + ), + ); + const groups = await tx + .delete(ProjectChallenge) + .where( + and( + eq(ProjectChallenge.hackathonId, hackathon.id), + eq(ProjectChallenge.isGroup, true), + ), + ) + .returning({ id: ProjectChallenge.id }); + const rubric = await tx + .delete(JudgingRubricItem) + .where(eq(JudgingRubricItem.hackathonId, hackathon.id)) + .returning({ id: JudgingRubricItem.id }); + await tx + .update(HackathonJudgingConfiguration) + .set({ challengeGroupsInitializedAt: null }) + .where(eq(HackathonJudgingConfiguration.hackathonId, hackathon.id)); + await initializeJudgingGroups(tx, hackathon.id); + await rebuildParentMemberships(tx, hackathon.id); + await createAdminAuditEvent( + { + actionKey: "judging.setup.reset", + actor, + metadata: { + groupCount: groups.length, + rubricItemCount: rubric.length, + }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { groupCount: groups.length, rubricItemCount: rubric.length }; + }); + }), + + resetLaunch: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + const claims = await tx + .delete(ProjectClaim) + .where(eq(ProjectClaim.hackathonId, hackathon.id)) + .returning({ memberId: ProjectClaim.memberId }); + const memberIds = tx + .select({ id: ProjectMember.id }) + .from(ProjectMember) + .innerJoin(Project, eq(Project.id, ProjectMember.projectId)) + .where(eq(Project.hackathonId, hackathon.id)); + const links = await tx + .delete(ProjectClaimLink) + .where(inArray(ProjectClaimLink.memberId, memberIds)) + .returning({ id: ProjectClaimLink.id }); + await tx + .insert(HackathonJudgingConfiguration) + .values({ hackathonId: hackathon.id }) + .onConflictDoUpdate({ + target: HackathonJudgingConfiguration.hackathonId, + set: { + closedAt: null, + displayAllResultsToMembers: false, + hackerScheduleEmergency: false, + hackerSchedulePublished: false, + openedAt: null, + projectClaimsStartedAt: null, + projectClaimUrl: null, + state: "draft", + }, + }); + await createAdminAuditEvent( + { + actionKey: "judging.launch.reset", + actor, + metadata: { + claimCount: claims.length, + claimLinkCount: links.length, + }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { claimCount: claims.length, claimLinkCount: links.length }; + }); + }), + + resetHackathon: permProcedure + .input(judgingDestructiveActionSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const hackathon = await lockConfirmedHackathon(tx, input); + const drafts = await tx + .delete(ProjectEvaluationDraft) + .where(eq(ProjectEvaluationDraft.hackathonId, hackathon.id)) + .returning({ id: ProjectEvaluationDraft.id }); + const evaluations = await tx + .delete(ProjectEvaluation) + .where(eq(ProjectEvaluation.hackathonId, hackathon.id)) + .returning({ id: ProjectEvaluation.id }); + await tx + .delete(JudgeDeliberationSection) + .where(eq(JudgeDeliberationSection.hackathonId, hackathon.id)); + const schedules = await tx + .delete(JudgingSchedule) + .where(eq(JudgingSchedule.hackathonId, hackathon.id)) + .returning({ id: JudgingSchedule.id }); + await tx + .delete(JudgingScheduleJob) + .where(eq(JudgingScheduleJob.hackathonId, hackathon.id)); + await tx + .delete(JudgingAnnouncement) + .where(eq(JudgingAnnouncement.hackathonId, hackathon.id)); + const rooms = await tx + .delete(JudgingRoom) + .where(eq(JudgingRoom.hackathonId, hackathon.id)) + .returning({ id: JudgingRoom.id }); + await tx.delete(Judge).where(eq(Judge.hackathonId, hackathon.id)); + await tx + .delete(ProjectClaim) + .where(eq(ProjectClaim.hackathonId, hackathon.id)); + const projects = await tx + .delete(Project) + .where(eq(Project.hackathonId, hackathon.id)) + .returning({ id: Project.id }); + await tx + .delete(ProjectChallenge) + .where(eq(ProjectChallenge.hackathonId, hackathon.id)); + await tx + .delete(JudgingRubricItem) + .where(eq(JudgingRubricItem.hackathonId, hackathon.id)); + await tx + .delete(HackathonJudgingConfiguration) + .where(eq(HackathonJudgingConfiguration.hackathonId, hackathon.id)); + await initializeJudgingGroups(tx, hackathon.id); + await createAdminAuditEvent( + { + actionKey: "judging.reset", + actor, + metadata: { + draftCount: drafts.length, + evaluationCount: evaluations.length, + hadSchedule: schedules.length > 0, + projectCount: projects.length, + roomCount: rooms.length, + }, + subjects: [ + { + relation: "primary", + targetId: hackathon.id, + targetLabel: hackathon.displayName, + targetType: "hackathon", + }, + ], + }, + tx, + ); + return { + evaluationCount: evaluations.length, + projectCount: projects.length, + roomCount: rooms.length, + }; + }); + }), + createRoom: permProcedure .input(judgingRoomCreateSchema) .mutation(async ({ ctx, input }) => { @@ -1446,6 +1895,30 @@ export const judgingRouter = createTRPCRouter({ }); }), + deleteRoom: permProcedure + .input(judgingRoomDeleteSchema) + .mutation(async ({ ctx, input }) => { + assertCanManageProjects(ctx); + const actor = await captureAdminAuditActor(ctx.session.user); + return db.transaction(async (tx) => { + const room = await lockRoomAggregate(tx, input.roomId); + await assertNoRoomReservations(tx, room.id); + if (input.confirmation !== room.name) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The confirmation does not match the room name.", + }); + await tx.delete(JudgingRoom).where(eq(JudgingRoom.id, room.id)); + await writeJudgingAudit(tx, { + actionKey: "judging.room.deleted", + actor, + roomId: room.id, + roomName: room.name, + }); + return { id: room.id, name: room.name }; + }); + }), + generateRoomLink: permProcedure .input(judgingRoomIdSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/tests/hacker/access.test.ts b/packages/api/src/tests/hacker/access.test.ts index 32ef0e12d..27a0679d1 100644 --- a/packages/api/src/tests/hacker/access.test.ts +++ b/packages/api/src/tests/hacker/access.test.ts @@ -113,6 +113,23 @@ const PROCEDURES: [string, (caller: Caller) => Promise][] = [ status: "accepted", }), ], + [ + "previewBulkDelete", + (caller) => + caller.previewBulkDelete({ + attendeeIds: [ATTENDEE_ID], + hackathonId: HACKATHON_ID, + }), + ], + [ + "confirmBulkDelete", + (caller) => + caller.confirmBulkDelete({ + attendeeIds: [ATTENDEE_ID], + confirmed: true, + hackathonId: HACKATHON_ID, + }), + ], [ "deleteApplication", (caller) => diff --git a/packages/api/src/tests/integration/hacker-guards.test.ts b/packages/api/src/tests/integration/hacker-guards.test.ts index 86c4ab107..49b433f94 100644 --- a/packages/api/src/tests/integration/hacker-guards.test.ts +++ b/packages/api/src/tests/integration/hacker-guards.test.ts @@ -302,7 +302,12 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { .where(eq(knightHacks.Hackathon.id, READY_HACKATHON)); await client .update(knightHacks.HackerAttendee) - .set({ lastStatusSendId: null, status: "pending" }) + .set({ + checkedInAt: null, + checkedInBy: null, + lastStatusSendId: null, + status: "pending", + }) .where(eq(knightHacks.HackerAttendee.hackathonId, READY_HACKATHON)); await client .update(knightHacks.HackerAttendee) @@ -310,6 +315,8 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { blacklistReason: null, blacklistedAt: null, blacklistedBy: null, + checkedInAt: null, + checkedInBy: null, lastStatusSendId: null, status: "pending", }) @@ -447,6 +454,23 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { expect(row?.lastStatusSendId).not.toBeNull(); }); + it("does not let a delegated editor check in hackers", async () => { + await client + .update(auth.Roles) + .set({ permissions: permissionBitstring("EDIT_HACKERS") }) + .where(eq(auth.Roles.id, OFFICER_ROLE)); + + for (const operation of ["previewBulk", "confirmBulk"] as const) { + await expect( + caller.hacker[operation]({ + attendeeIds: [PLAIN_ATTENDEE], + hackathonId: READY_HACKATHON, + status: "checkedin", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + } + }); + it("preserves blacklist safeguards without disclosing them to editors", async () => { await client .update(auth.Roles) @@ -490,6 +514,28 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { ]); } } + await expect( + caller.hacker.previewBulkDelete({ + attendeeIds: [BLACKLISTED_ATTENDEE], + hackathonId: READY_HACKATHON, + }), + ).resolves.toEqual({ + deleting: [], + skipped: [ + { + attendeeId: BLACKLISTED_ATTENDEE, + name: "Test blocked", + reason: null, + }, + ], + }); + await expect( + caller.hacker.confirmBulkDelete({ + attendeeIds: [BLACKLISTED_ATTENDEE], + confirmed: true, + hackathonId: READY_HACKATHON, + }), + ).resolves.toMatchObject({ deletedCount: 0 }); const blocked = await client.query.HackerAttendee.findFirst({ where: eq(knightHacks.HackerAttendee.id, BLACKLISTED_ATTENDEE), }); @@ -618,6 +664,49 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { }); describe("gaps the first review found", () => { + it("checks in selected hackers without requiring or sending email", async () => { + await client + .update(knightHacks.Hacker) + .set({ email: " " }) + .where(eq(knightHacks.Hacker.id, UNREADY_HACKER)); + const input = { + attendeeIds: [UNREADY_ATTENDEE], + hackathonId: UNREADY_HACKATHON, + status: "checkedin" as const, + }; + + await expect(caller.hacker.previewBulk(input)).resolves.toMatchObject({ + sending: [expect.objectContaining({ attendeeId: UNREADY_ATTENDEE })], + skipped: [], + }); + const result = await caller.hacker.confirmBulk(input); + + expect(result).toMatchObject({ + movedCount: 1, + sendId: null, + skipped: [], + withheldCount: 0, + }); + const [attendee] = await client + .select({ + checkedInAt: knightHacks.HackerAttendee.checkedInAt, + checkedInBy: knightHacks.HackerAttendee.checkedInBy, + lastStatusSendId: knightHacks.HackerAttendee.lastStatusSendId, + status: knightHacks.HackerAttendee.status, + }) + .from(knightHacks.HackerAttendee) + .where(eq(knightHacks.HackerAttendee.id, UNREADY_ATTENDEE)); + expect(attendee?.checkedInAt).toBeInstanceOf(Date); + expect(attendee).toMatchObject({ + checkedInBy: OFFICER_USER, + lastStatusSendId: null, + status: "checkedin", + }); + await expect( + client.select().from(knightHacks.EmailSend), + ).resolves.toEqual([]); + }); + it("confirmBulk refuses on an unconfigured hackathon", async () => { // The readiness gate on the bulk path had a positive control and no // negative case, so deleting it entirely left the suite green while an @@ -1418,6 +1507,217 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { }); describe("application deletion", () => { + async function createDeleteFixture( + sequence: 2 | 3 | 4, + blacklisted = false, + ) { + const suffix = `0000000000f${sequence}`; + const userId = `10000000-0000-4000-8000-${suffix}`; + const hackerId = `70000000-0000-4000-8000-${suffix}`; + const attendeeId = `60000000-0000-4000-8000-${suffix}`; + const profileId = `90000000-0000-4000-8000-${suffix}`; + const profileRevisionId = `91000000-0000-4000-8000-${suffix}`; + const profileFields = { + country: "United States of America" as const, + discordUser: `bulk-delete-${sequence}`, + dob: "2006-01-01", + email: `bulk-delete-${sequence}@example.test`, + firstName: "Bulk", + foodAllergies: null, + gender: "Prefer not to answer" as const, + githubProfileUrl: null, + gradDate: "2030-05-01", + lastName: `Delete ${sequence}`, + levelOfStudy: "Undergraduate University (3+ year)" as const, + linkedinProfileUrl: null, + major: "Computer Science" as const, + phoneNumber: `000000000${sequence}`, + raceOrEthnicity: "Prefer not to answer" as const, + school: "University of Central Florida", + shirtSize: "M" as const, + websiteUrl: null, + }; + + await client.insert(auth.User).values({ + discordUserId: `discord-bulk-delete-${sequence}`, + id: userId, + }); + await client.insert(knightHacks.HackerProfile).values({ + ...profileFields, + id: profileId, + resumeUrl: null, + userId, + }); + await client.insert(knightHacks.Hacker).values({ + ...profileFields, + age: 20, + id: hackerId, + resumeUrl: null, + survey1: "", + survey2: "", + userId, + }); + await client.insert(knightHacks.HackerProfileRevision).values({ + ...profileFields, + id: profileRevisionId, + legacyHackerId: hackerId, + profileId, + resumeUrl: null, + revision: 1, + }); + await client.insert(knightHacks.HackerAttendee).values({ + blacklistReason: blacklisted ? "Officer-reviewed deletion." : null, + blacklistedAt: blacklisted ? new Date() : null, + blacklistedBy: blacklisted ? OFFICER_USER : null, + hackerId, + hackathonId: READY_HACKATHON, + id: attendeeId, + profileId, + profileRevisionId, + status: "pending", + }); + await client.insert(knightHacks.HackerParticipantCommand).values({ + completedAt: new Date(), + expiresAt: since(1), + hackathonId: READY_HACKATHON, + idempotencyKey: `bulk-delete-${sequence}`, + operation: "submit_application", + payloadHash: String(sequence).repeat(64), + result: { attendeeId }, + state: "completed", + userId, + }); + + return { + attendeeId, + hackerId, + profileId, + profileRevisionId, + userId, + }; + } + + it("previews and permanently deletes a selection without email", async () => { + const fixtures = await Promise.all([ + createDeleteFixture(2), + createDeleteFixture(3), + ]); + const attendeeIds = fixtures.map((row) => row.attendeeId); + + try { + await expect( + caller.hacker.previewBulkDelete({ + attendeeIds, + hackathonId: READY_HACKATHON, + }), + ).resolves.toMatchObject({ + deleting: [expect.anything(), expect.anything()], + skipped: [], + }); + await expect( + caller.hacker.confirmBulkDelete({ + attendeeIds, + confirmed: true, + hackathonId: READY_HACKATHON, + }), + ).resolves.toEqual({ deletedCount: 2, skipped: [] }); + + await expect( + client + .select({ id: knightHacks.HackerAttendee.id }) + .from(knightHacks.HackerAttendee) + .where(inArray(knightHacks.HackerAttendee.id, attendeeIds)), + ).resolves.toEqual([]); + await expect( + client + .select({ id: knightHacks.HackerParticipantCommand.id }) + .from(knightHacks.HackerParticipantCommand) + .where( + inArray( + knightHacks.HackerParticipantCommand.userId, + fixtures.map((row) => row.userId), + ), + ), + ).resolves.toEqual([]); + await expect( + client + .select({ id: knightHacks.Hacker.id }) + .from(knightHacks.Hacker) + .where( + inArray( + knightHacks.Hacker.id, + fixtures.map((row) => row.hackerId), + ), + ), + ).resolves.toEqual([]); + await expect( + client + .select({ id: knightHacks.HackerProfile.id }) + .from(knightHacks.HackerProfile) + .where( + inArray( + knightHacks.HackerProfile.id, + fixtures.map((row) => row.profileId), + ), + ), + ).resolves.toHaveLength(2); + await expect( + client + .select({ id: knightHacks.HackerProfileRevision.id }) + .from(knightHacks.HackerProfileRevision) + .where( + inArray( + knightHacks.HackerProfileRevision.id, + fixtures.map((row) => row.profileRevisionId), + ), + ), + ).resolves.toHaveLength(2); + await expect( + client.select().from(knightHacks.EmailSend), + ).resolves.toEqual([]); + } finally { + await client.delete(auth.User).where( + inArray( + auth.User.id, + fixtures.map((row) => row.userId), + ), + ); + } + }); + + it("allows an officer to delete a blacklisted application", async () => { + const fixture = await createDeleteFixture(4, true); + + try { + await expect( + caller.hacker.previewBulkDelete({ + attendeeIds: [fixture.attendeeId], + hackathonId: READY_HACKATHON, + }), + ).resolves.toMatchObject({ + deleting: [ + expect.objectContaining({ attendeeId: fixture.attendeeId }), + ], + skipped: [], + }); + await expect( + caller.hacker.confirmBulkDelete({ + attendeeIds: [fixture.attendeeId], + confirmed: true, + hackathonId: READY_HACKATHON, + }), + ).resolves.toEqual({ deletedCount: 1, skipped: [] }); + await expect( + client + .select() + .from(knightHacks.HackerAttendee) + .where(eq(knightHacks.HackerAttendee.id, fixture.attendeeId)), + ).resolves.toHaveLength(0); + } finally { + await client.delete(auth.User).where(eq(auth.User.id, fixture.userId)); + } + }); + it("removes the application while preserving the reusable profile", async () => { const userId = "10000000-0000-4000-8000-0000000000f1"; const hackerId = "70000000-0000-4000-8000-0000000000f1"; diff --git a/packages/api/src/tests/integration/judging-reset.test.ts b/packages/api/src/tests/integration/judging-reset.test.ts new file mode 100644 index 000000000..ad4f2bbdc --- /dev/null +++ b/packages/api/src/tests/integration/judging-reset.test.ts @@ -0,0 +1,1154 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { Session } from "@forge/auth/server"; +import type { DisposableDatabase } from "@forge/db/testing"; +import { eq } from "@forge/db"; +import { + canRunDatabaseTests, + provisionDisposableDatabase, +} from "@forge/db/testing"; + +import { permissionBitstring } from "../support/permissions"; + +describe.runIf(canRunDatabaseTests())("judging reset operations", () => { + let disposable: DisposableDatabase; + let client: typeof import("@forge/db/client").db; + let schema: typeof import("@forge/db/schemas/knight-hacks"); + let caller: Awaited>; + let unauthorizedCaller: Awaited>; + + const officerId = randomUUID(); + const unauthorizedUserId = randomUUID(); + const hackathonId = randomUUID(); + const challengeId = randomUUID(); + const projectId = randomUUID(); + const memberId = randomUUID(); + const roomId = randomUUID(); + const spareRoomId = randomUUID(); + const judgeId = randomUUID(); + const spareJudgeId = randomUUID(); + const spareLinkId = randomUUID(); + const scheduleId = randomUUID(); + const appointmentId = randomUUID(); + const scheduleJobId = randomUUID(); + const evaluationId = randomUUID(); + const rubricId = randomUUID(); + const responseRubricId = randomUUID(); + const buildingId = randomUUID(); + + async function createCaller(userId: string, name: string) { + const trpc = await import("../../trpc"); + const { judgingRouter } = await import("../../routers/judging"); + return trpc.createCallerFactory( + trpc.createTRPCRouter({ judging: judgingRouter }), + )({ + headers: new Headers(), + session: { + session: { id: "judging-reset", userAgent: "vitest" }, + user: { id: userId, name }, + } as unknown as Session, + source: "judging-reset-integration", + }); + } + + function createOfficerCaller() { + return createCaller(officerId, "Reset Officer"); + } + + async function insertEvaluation() { + await client.insert(schema.ProjectEvaluation).values({ + appointmentId, + challengeId, + hackathonId, + id: evaluationId, + judgeId, + projectId, + }); + await client.insert(schema.ProjectEvaluationDraft).values({ + appointmentId, + baseEvaluationRevision: 1, + challengeId, + evaluationId, + hackathonId, + judgeId, + projectId, + }); + await client.insert(schema.ProjectEvaluationRevision).values({ + actorKind: "member", + evaluationId, + hackathonId, + revision: 1, + }); + await client.insert(schema.ProjectEvaluationRating).values({ + evaluationId, + hackathonId, + rubricItemId: rubricId, + value: 4, + }); + await client.insert(schema.ProjectEvaluationResponse).values({ + evaluationId, + hackathonId, + isPublic: true, + rubricItemId: responseRubricId, + value: "Reset feedback", + }); + } + + beforeAll(async () => { + disposable = await provisionDisposableDatabase("judging_reset"); + vi.stubEnv("DATABASE_URL", disposable.url); + client = (await import("@forge/db/client")).db; + schema = await import("@forge/db/schemas/knight-hacks"); + const auth = await import("@forge/db/schemas/auth"); + const roleId = randomUUID(); + await client.insert(auth.User).values([ + { + discordUserId: "reset-officer", + id: officerId, + name: "Reset Officer", + }, + { + discordUserId: "reset-unauthorized", + id: unauthorizedUserId, + name: "Reset Unauthorized", + }, + ]); + await client.insert(auth.Roles).values({ + discordRoleId: "991000000000000001", + id: roleId, + name: "Reset officers", + permissions: permissionBitstring("IS_OFFICER"), + }); + await client.insert(auth.Permissions).values({ roleId, userId: officerId }); + await client.insert(schema.Hackathon).values({ + displayName: "Reset Test Hackathon", + endDate: new Date("2026-10-02T00:00:00Z"), + id: hackathonId, + name: "reset-test", + startDate: new Date("2026-10-01T00:00:00Z"), + theme: "Reset", + }); + await client.insert(schema.HackathonJudgingConfiguration).values({ + challengeGroupsInitializedAt: new Date(), + displayAllResultsToMembers: true, + hackerScheduleEmergency: true, + hackerSchedulePublished: true, + hackathonId, + projectClaimUrl: "https://example.test/claim", + projectClaimsStartedAt: new Date(), + projectInventoryLockedAt: new Date(), + state: "open", + }); + await client.insert(schema.JudgingBuilding).values({ + id: buildingId, + name: "Reset Test Building", + }); + await client.insert(schema.ProjectChallenge).values({ + hackathonId, + id: challengeId, + isGeneral: true, + isGroup: true, + label: "Custom General", + }); + await client.insert(schema.Project).values({ + description: "Reset this project", + hackathonId, + id: projectId, + participantCount: 1, + projectCreatedAt: new Date(), + submissionUrl: "https://reset.example.test", + submittedAt: new Date(), + title: "Reset project", + }); + await client.insert(schema.ProjectMember).values({ + displayOrder: 0, + email: "reset@example.test", + id: memberId, + name: "Reset Hacker", + projectId, + }); + await client.insert(schema.ProjectClaim).values({ + hackathonId, + memberId, + projectId, + userId: officerId, + }); + await client.insert(schema.ProjectClaimLink).values({ + memberId, + sentAt: new Date(), + token: "a".repeat(64), + }); + await client.insert(schema.ProjectToChallenge).values({ + challengeId, + hackathonId, + isOptIn: false, + projectId, + }); + await client.insert(schema.JudgingRubricItem).values([ + { + displayOrder: 0, + hackathonId, + id: rubricId, + kind: "rating", + label: "Reset rating", + }, + { + displayOrder: 1, + guestVisibilityPolicy: "private", + hackathonId, + id: responseRubricId, + kind: "short_response", + label: "Reset response", + memberVisibilityPolicy: "public", + }, + ]); + await client.insert(schema.JudgingRoom).values([ + { challengeId, hackathonId, id: roomId, name: "Scheduled room" }, + { challengeId, hackathonId, id: spareRoomId, name: "Spare room" }, + ]); + await client.insert(schema.JudgingRoomAccessLink).values({ + createdByUserId: officerId, + hackathonId, + id: spareLinkId, + roomId: spareRoomId, + }); + await client.insert(schema.Judge).values([ + { + displayName: "Reset Judge", + hackathonId, + id: judgeId, + kind: "member", + userId: officerId, + }, + { + displayName: "Spare Guest", + hackathonId, + id: spareJudgeId, + kind: "guest", + }, + ]); + await client.insert(schema.GuestJudgeSession).values({ + accessLinkId: spareLinkId, + completedAt: new Date(), + expiresAt: new Date("2027-01-01T00:00:00Z"), + hackathonId, + judgeId: spareJudgeId, + tokenHash: "b".repeat(64), + }); + await client.insert(schema.JudgingRoomPresence).values([ + { hackathonId, judgeId, roomId }, + { hackathonId, judgeId: spareJudgeId, roomId: spareRoomId }, + ]); + await client.insert(schema.JudgingSchedule).values({ + differentBuildingBreakMinutes: 20, + endsAt: new Date("2026-10-01T18:00:00Z"), + firstResultAt: new Date(), + hackathonId, + id: scheduleId, + judgingMinutes: 6, + sameBuildingBreakMinutes: 10, + savedByUserId: officerId, + setupMinutes: 2, + startsAt: new Date("2026-10-01T17:00:00Z"), + teardownMinutes: 2, + }); + await client.insert(schema.JudgingScheduleJob).values({ + checkpoint: {}, + createdByUserId: officerId, + expiresAt: new Date("2026-10-01T19:00:00Z"), + hackathonId, + id: scheduleJobId, + problem: {}, + sourceFingerprint: "reset-fixture", + timing: {}, + }); + await client.insert(schema.JudgingAppointment).values({ + challengeId, + deadlineAt: new Date("2026-10-01T17:08:00Z"), + endsAt: new Date("2026-10-01T17:10:00Z"), + hackathonId, + id: appointmentId, + projectId, + roomId, + scheduleId, + startsAt: new Date("2026-10-01T17:00:00Z"), + }); + await insertEvaluation(); + const deliberationSectionId = randomUUID(); + await client.insert(schema.JudgeDeliberationSection).values({ + displayOrder: 0, + hackathonId, + id: deliberationSectionId, + judgeId, + name: "Finalists", + }); + await client.insert(schema.JudgeDeliberationEntry).values({ + displayOrder: 0, + hackathonId, + projectId, + sectionId: deliberationSectionId, + }); + await client.insert(schema.JudgingAnnouncement).values([ + { + hackathonId, + message: "Reset announcement", + publishedByUserId: officerId, + roomId, + }, + { + hackathonId, + message: "Spare room announcement", + publishedByUserId: officerId, + roomId: spareRoomId, + }, + ]); + caller = await createOfficerCaller(); + unauthorizedCaller = await createCaller( + unauthorizedUserId, + "Reset Unauthorized", + ); + }, 120_000); + + afterAll(async () => { + await client.$client.end().catch(() => undefined); + await disposable.drop(); + }, 30_000); + + it("rejects every destructive judging operation without project-management access", async () => { + const operations = [ + () => + unauthorizedCaller.judging.dropEvaluations({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => + unauthorizedCaller.judging.dropRooms({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => unauthorizedCaller.judging.dropSchedule({ hackathonId }), + () => + unauthorizedCaller.judging.resetProjects({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => + unauthorizedCaller.judging.resetSetup({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => + unauthorizedCaller.judging.resetLaunch({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => + unauthorizedCaller.judging.resetHackathon({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + () => + unauthorizedCaller.judging.deleteRoom({ + confirmation: "Spare room", + roomId: spareRoomId, + }), + ]; + + for (const operation of operations) + await expect(operation()).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("drops saved and preview-only schedule jobs with the schedule slice", async () => { + const savedHackathonId = randomUUID(); + const previewHackathonId = randomUUID(); + const savedScheduleId = randomUUID(); + await client.insert(schema.Hackathon).values([ + { + displayName: "Saved Schedule Reset Hackathon", + endDate: new Date("2030-10-02T00:00:00Z"), + id: savedHackathonId, + name: `saved-schedule-reset-${savedHackathonId}`, + startDate: new Date("2030-10-01T00:00:00Z"), + theme: "Reset", + }, + { + displayName: "Preview Schedule Reset Hackathon", + endDate: new Date("2031-10-02T00:00:00Z"), + id: previewHackathonId, + name: `preview-schedule-reset-${previewHackathonId}`, + startDate: new Date("2031-10-01T00:00:00Z"), + theme: "Reset", + }, + ]); + + try { + await client.insert(schema.JudgingSchedule).values({ + differentBuildingBreakMinutes: 20, + endsAt: new Date("2030-10-01T18:00:00Z"), + hackathonId: savedHackathonId, + id: savedScheduleId, + judgingMinutes: 6, + sameBuildingBreakMinutes: 10, + savedByUserId: officerId, + setupMinutes: 2, + startsAt: new Date("2030-10-01T17:00:00Z"), + teardownMinutes: 2, + }); + await client.insert(schema.JudgingScheduleJob).values([ + { + checkpoint: {}, + createdByUserId: officerId, + expiresAt: new Date("2030-10-01T19:00:00Z"), + hackathonId: savedHackathonId, + problem: {}, + sourceFingerprint: "saved-reset-fixture", + status: "saved", + timing: {}, + }, + { + checkpoint: {}, + createdByUserId: officerId, + expiresAt: new Date("2031-10-01T19:00:00Z"), + hackathonId: previewHackathonId, + problem: {}, + sourceFingerprint: "preview-reset-fixture", + status: "feasible", + timing: {}, + }, + ]); + + await caller.judging.dropSchedule({ hackathonId: savedHackathonId }); + await caller.judging.dropSchedule({ hackathonId: previewHackathonId }); + + await expect( + client + .select() + .from(schema.JudgingSchedule) + .where(eq(schema.JudgingSchedule.hackathonId, savedHackathonId)), + ).resolves.toHaveLength(0); + for (const scopedHackathonId of [savedHackathonId, previewHackathonId]) { + await expect( + client + .select() + .from(schema.JudgingScheduleJob) + .where( + eq(schema.JudgingScheduleJob.hackathonId, scopedHackathonId), + ), + ).resolves.toHaveLength(0); + } + } finally { + await client + .delete(schema.Hackathon) + .where(eq(schema.Hackathon.id, savedHackathonId)); + await client + .delete(schema.Hackathon) + .where(eq(schema.Hackathon.id, previewHackathonId)); + } + }); + + it("drops evaluations without disturbing the saved schedule", async () => { + await expect( + caller.judging.dropEvaluations({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + ).resolves.toEqual({ draftCount: 1, evaluationCount: 1 }); + await expect( + client + .select() + .from(schema.ProjectEvaluation) + .where(eq(schema.ProjectEvaluation.hackathonId, hackathonId)), + ).resolves.toHaveLength(0); + for (const table of [ + schema.ProjectEvaluationDraft, + schema.ProjectEvaluationRating, + schema.ProjectEvaluationResponse, + schema.ProjectEvaluationRevision, + ]) { + await expect( + client.select().from(table).where(eq(table.hackathonId, hackathonId)), + ).resolves.toHaveLength(0); + } + await expect( + client + .select({ firstResultAt: schema.JudgingSchedule.firstResultAt }) + .from(schema.JudgingSchedule) + .where(eq(schema.JudgingSchedule.hackathonId, hackathonId)), + ).resolves.toEqual([{ firstResultAt: null }]); + await insertEvaluation(); + }); + + it("permanently deletes an unreserved room and its access data", async () => { + await expect( + caller.judging.deleteRoom({ + confirmation: "Scheduled room", + roomId, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + await expect( + caller.judging.deleteRoom({ + confirmation: "Spare room", + roomId: spareRoomId, + }), + ).resolves.toEqual({ id: spareRoomId, name: "Spare room" }); + await expect( + client + .select() + .from(schema.JudgingRoomAccessLink) + .where(eq(schema.JudgingRoomAccessLink.roomId, spareRoomId)), + ).resolves.toHaveLength(0); + await expect( + client + .select() + .from(schema.GuestJudgeSession) + .where(eq(schema.GuestJudgeSession.accessLinkId, spareLinkId)), + ).resolves.toHaveLength(0); + await expect( + client + .select() + .from(schema.JudgingRoomPresence) + .where(eq(schema.JudgingRoomPresence.roomId, spareRoomId)), + ).resolves.toHaveLength(0); + await expect( + client + .select() + .from(schema.JudgingAnnouncement) + .where(eq(schema.JudgingAnnouncement.roomId, spareRoomId)), + ).resolves.toHaveLength(0); + }); + + it("enforces dependencies and resets rooms, setup, and launch independently", async () => { + await expect( + caller.judging.dropRooms({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await expect( + caller.judging.resetSetup({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + const roomsHackathonId = randomUUID(); + const roomsChallengeId = randomUUID(); + const roomsRoomId = randomUUID(); + await client.insert(schema.Hackathon).values({ + displayName: "Rooms Reset Hackathon", + endDate: new Date("2027-10-02T00:00:00Z"), + id: roomsHackathonId, + name: `rooms-reset-${roomsHackathonId}`, + startDate: new Date("2027-10-01T00:00:00Z"), + theme: "Reset", + }); + await client.insert(schema.ProjectChallenge).values({ + hackathonId: roomsHackathonId, + id: roomsChallengeId, + isGroup: true, + label: "Rooms group", + }); + await client.insert(schema.JudgingRoom).values({ + challengeId: roomsChallengeId, + hackathonId: roomsHackathonId, + id: roomsRoomId, + name: "Room to drop", + }); + await client.insert(schema.JudgingAnnouncement).values({ + hackathonId: roomsHackathonId, + message: "Room reset announcement", + publishedByUserId: officerId, + roomId: roomsRoomId, + }); + await expect( + caller.judging.dropRooms({ + confirmation: "Rooms Reset Hackathon", + hackathonId: roomsHackathonId, + }), + ).resolves.toEqual({ roomCount: 1 }); + await expect( + client + .select() + .from(schema.JudgingRoom) + .where(eq(schema.JudgingRoom.hackathonId, roomsHackathonId)), + ).resolves.toHaveLength(0); + await expect( + client + .select() + .from(schema.JudgingAnnouncement) + .where(eq(schema.JudgingAnnouncement.hackathonId, roomsHackathonId)), + ).resolves.toHaveLength(0); + + const setupHackathonId = randomUUID(); + const setupGroupId = randomUUID(); + const setupChallengeId = randomUUID(); + const setupProjectId = randomUUID(); + const setupRubricId = randomUUID(); + await client.insert(schema.Hackathon).values({ + displayName: "Setup Reset Hackathon", + endDate: new Date("2028-10-02T00:00:00Z"), + id: setupHackathonId, + name: `setup-reset-${setupHackathonId}`, + startDate: new Date("2028-10-01T00:00:00Z"), + theme: "Reset", + }); + await client.insert(schema.HackathonJudgingConfiguration).values({ + challengeGroupsInitializedAt: new Date(), + hackathonId: setupHackathonId, + }); + await client.insert(schema.ProjectChallenge).values({ + hackathonId: setupHackathonId, + id: setupGroupId, + isGroup: true, + label: "Custom setup group", + }); + await client.insert(schema.ProjectChallenge).values({ + hackathonId: setupHackathonId, + id: setupChallengeId, + isGroup: false, + isScheduled: false, + label: "Imported sponsor challenge", + parentId: setupGroupId, + }); + await client.insert(schema.Project).values({ + description: "Project preserved through setup reset", + hackathonId: setupHackathonId, + id: setupProjectId, + participantCount: 1, + projectCreatedAt: new Date(), + submissionUrl: "https://setup-reset.example.test", + submittedAt: new Date(), + title: "Setup reset project", + }); + await client.insert(schema.ProjectToChallenge).values({ + challengeId: setupChallengeId, + hackathonId: setupHackathonId, + isOptIn: true, + projectId: setupProjectId, + }); + await client.insert(schema.JudgingRubricItem).values({ + displayOrder: 0, + hackathonId: setupHackathonId, + id: setupRubricId, + kind: "rating", + label: "Setup rating", + }); + await expect( + caller.judging.resetSetup({ + confirmation: "Setup Reset Hackathon", + hackathonId: setupHackathonId, + }), + ).resolves.toEqual({ groupCount: 1, rubricItemCount: 1 }); + await expect( + client + .select() + .from(schema.JudgingRubricItem) + .where(eq(schema.JudgingRubricItem.hackathonId, setupHackathonId)), + ).resolves.toHaveLength(0); + const setupGroups = await client + .select({ label: schema.ProjectChallenge.label }) + .from(schema.ProjectChallenge) + .where(eq(schema.ProjectChallenge.hackathonId, setupHackathonId)); + expect(setupGroups).toEqual( + expect.arrayContaining([ + { label: "General" }, + { label: "MLH Challenges" }, + ]), + ); + await expect( + client + .select({ + isScheduled: schema.ProjectChallenge.isScheduled, + parentId: schema.ProjectChallenge.parentId, + }) + .from(schema.ProjectChallenge) + .where(eq(schema.ProjectChallenge.id, setupChallengeId)), + ).resolves.toEqual([{ isScheduled: true, parentId: null }]); + const generalGroup = await client.query.ProjectChallenge.findFirst({ + columns: { id: true }, + where: (challenge, { and, eq }) => + and( + eq(challenge.hackathonId, setupHackathonId), + eq(challenge.isGeneral, true), + eq(challenge.isGroup, true), + ), + }); + if (!generalGroup) throw new Error("Reset did not restore General."); + await expect( + client + .select({ + challengeId: schema.ProjectToChallenge.challengeId, + isOptIn: schema.ProjectToChallenge.isOptIn, + }) + .from(schema.ProjectToChallenge) + .where(eq(schema.ProjectToChallenge.projectId, setupProjectId)), + ).resolves.toEqual( + expect.arrayContaining([ + { challengeId: setupChallengeId, isOptIn: true }, + { challengeId: generalGroup.id, isOptIn: false }, + ]), + ); + + const launchHackathonId = randomUUID(); + const launchProjectId = randomUUID(); + const launchMemberId = randomUUID(); + await client.insert(schema.Hackathon).values({ + displayName: "Launch Reset Hackathon", + endDate: new Date("2029-10-02T00:00:00Z"), + id: launchHackathonId, + name: `launch-reset-${launchHackathonId}`, + startDate: new Date("2029-10-01T00:00:00Z"), + theme: "Reset", + }); + await client.insert(schema.HackathonJudgingConfiguration).values({ + displayAllResultsToMembers: true, + hackerScheduleEmergency: true, + hackerSchedulePublished: true, + hackathonId: launchHackathonId, + projectClaimUrl: "https://example.test/launch-claim", + projectClaimsStartedAt: new Date(), + state: "open", + }); + await client.insert(schema.Project).values({ + description: "Launch reset project", + hackathonId: launchHackathonId, + id: launchProjectId, + participantCount: 1, + projectCreatedAt: new Date(), + submissionUrl: "https://launch-reset.example.test", + submittedAt: new Date(), + title: "Launch reset project", + }); + await client.insert(schema.ProjectMember).values({ + displayOrder: 0, + email: "launch-reset@example.test", + id: launchMemberId, + name: "Launch Reset Hacker", + projectId: launchProjectId, + }); + await client.insert(schema.ProjectClaim).values({ + hackathonId: launchHackathonId, + memberId: launchMemberId, + projectId: launchProjectId, + userId: officerId, + }); + await client.insert(schema.ProjectClaimLink).values({ + memberId: launchMemberId, + token: "c".repeat(64), + }); + await expect( + caller.judging.resetLaunch({ + confirmation: "Launch Reset Hackathon", + hackathonId: launchHackathonId, + }), + ).resolves.toEqual({ claimCount: 1, claimLinkCount: 1 }); + await expect( + client + .select() + .from(schema.ProjectClaim) + .where(eq(schema.ProjectClaim.hackathonId, launchHackathonId)), + ).resolves.toHaveLength(0); + await expect( + client + .select() + .from(schema.ProjectClaimLink) + .where(eq(schema.ProjectClaimLink.memberId, launchMemberId)), + ).resolves.toHaveLength(0); + const [launchConfiguration] = await client + .select() + .from(schema.HackathonJudgingConfiguration) + .where( + eq(schema.HackathonJudgingConfiguration.hackathonId, launchHackathonId), + ); + expect(launchConfiguration).toMatchObject({ + displayAllResultsToMembers: false, + hackerScheduleEmergency: false, + hackerSchedulePublished: false, + projectClaimUrl: null, + projectClaimsStartedAt: null, + state: "draft", + }); + for (const [extraHackathonId, confirmation] of [ + [roomsHackathonId, "Rooms Reset Hackathon"], + [setupHackathonId, "Setup Reset Hackathon"], + [launchHackathonId, "Launch Reset Hackathon"], + ] as const) { + await caller.judging.resetHackathon({ + confirmation, + hackathonId: extraHackathonId, + }); + } + }); + + it("resets claimed project inventory after every downstream slice is cleared", async () => { + const targetHackathonId = randomUUID(); + const targetGroupId = randomUUID(); + const targetChallengeId = randomUUID(); + const targetProjectId = randomUUID(); + const targetMemberId = randomUUID(); + const targetRoomId = randomUUID(); + const targetScheduleId = randomUUID(); + const targetJobId = randomUUID(); + const targetAppointmentId = randomUUID(); + const targetJudgeId = randomUUID(); + const targetEvaluationId = randomUUID(); + const targetSectionId = randomUUID(); + + await client.insert(schema.Hackathon).values({ + displayName: "Project Reset Hackathon", + endDate: new Date("2030-10-02T00:00:00Z"), + id: targetHackathonId, + name: `project-reset-${targetHackathonId}`, + startDate: new Date("2030-10-01T00:00:00Z"), + theme: "Reset", + }); + + try { + await client.insert(schema.HackathonJudgingConfiguration).values({ + hackathonId: targetHackathonId, + projectClaimUrl: "https://example.test/project-reset-claim", + projectClaimsStartedAt: new Date(), + projectInventoryLockedAt: new Date(), + projectInventoryLockedByUserId: officerId, + }); + await client.insert(schema.ProjectChallenge).values([ + { + hackathonId: targetHackathonId, + id: targetGroupId, + isGroup: true, + label: "Project reset group", + }, + { + hackathonId: targetHackathonId, + id: targetChallengeId, + label: "Imported challenge", + parentId: targetGroupId, + }, + ]); + await client.insert(schema.Project).values({ + description: "Project reset fixture", + hackathonId: targetHackathonId, + id: targetProjectId, + participantCount: 1, + projectCreatedAt: new Date(), + submissionUrl: "https://project-reset.example.test", + submittedAt: new Date(), + title: "Project reset fixture", + }); + await client.insert(schema.ProjectMember).values({ + displayOrder: 0, + email: "project-reset@example.test", + id: targetMemberId, + name: "Project Reset Hacker", + projectId: targetProjectId, + }); + await client.insert(schema.ProjectToChallenge).values({ + challengeId: targetChallengeId, + hackathonId: targetHackathonId, + isOptIn: false, + projectId: targetProjectId, + }); + await client.insert(schema.ProjectClaim).values({ + hackathonId: targetHackathonId, + memberId: targetMemberId, + projectId: targetProjectId, + userId: officerId, + }); + await client.insert(schema.ProjectClaimLink).values({ + memberId: targetMemberId, + token: "d".repeat(64), + }); + await client.insert(schema.JudgingRoom).values({ + challengeId: targetGroupId, + hackathonId: targetHackathonId, + id: targetRoomId, + name: "Project reset room", + }); + await client.insert(schema.Judge).values({ + displayName: "Project Reset Judge", + hackathonId: targetHackathonId, + id: targetJudgeId, + kind: "member", + userId: officerId, + }); + await client.insert(schema.JudgingSchedule).values({ + differentBuildingBreakMinutes: 20, + endsAt: new Date("2030-10-01T18:00:00Z"), + hackathonId: targetHackathonId, + id: targetScheduleId, + judgingMinutes: 6, + sameBuildingBreakMinutes: 10, + savedByUserId: officerId, + setupMinutes: 2, + startsAt: new Date("2030-10-01T17:00:00Z"), + teardownMinutes: 2, + }); + await client.insert(schema.JudgingScheduleJob).values({ + checkpoint: {}, + createdByUserId: officerId, + expiresAt: new Date("2030-10-01T19:00:00Z"), + hackathonId: targetHackathonId, + id: targetJobId, + problem: {}, + sourceFingerprint: "project-reset-fixture", + timing: {}, + }); + await client.insert(schema.JudgingAppointment).values({ + challengeId: targetChallengeId, + deadlineAt: new Date("2030-10-01T17:08:00Z"), + endsAt: new Date("2030-10-01T17:10:00Z"), + hackathonId: targetHackathonId, + id: targetAppointmentId, + projectId: targetProjectId, + roomId: targetRoomId, + scheduleId: targetScheduleId, + startsAt: new Date("2030-10-01T17:00:00Z"), + }); + await client.insert(schema.ProjectEvaluation).values({ + appointmentId: targetAppointmentId, + challengeId: targetChallengeId, + hackathonId: targetHackathonId, + id: targetEvaluationId, + judgeId: targetJudgeId, + projectId: targetProjectId, + }); + await client.insert(schema.JudgeDeliberationSection).values({ + displayOrder: 0, + hackathonId: targetHackathonId, + id: targetSectionId, + judgeId: targetJudgeId, + name: "Project reset finalists", + }); + await client.insert(schema.JudgeDeliberationEntry).values({ + displayOrder: 0, + hackathonId: targetHackathonId, + projectId: targetProjectId, + sectionId: targetSectionId, + }); + + await expect( + caller.judging.resetProjects({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + + await caller.judging.dropEvaluations({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }); + await expect( + caller.judging.resetProjects({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await expect( + caller.judging.dropSchedule({ hackathonId: targetHackathonId }), + ).resolves.toEqual({ dropped: true, jobCount: 1 }); + await expect( + caller.judging.resetProjects({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await caller.judging.dropRooms({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }); + await client.insert(schema.JudgingScheduleJob).values({ + checkpoint: {}, + createdByUserId: officerId, + expiresAt: new Date("2030-10-01T20:00:00Z"), + hackathonId: targetHackathonId, + problem: {}, + sourceFingerprint: "project-reset-unsaved-fixture", + timing: {}, + }); + await expect( + caller.judging.resetProjects({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await expect( + caller.judging.dropSchedule({ hackathonId: targetHackathonId }), + ).resolves.toEqual({ dropped: false, jobCount: 1 }); + await expect( + caller.judging.resetProjects({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }), + ).resolves.toEqual({ projectCount: 1 }); + + await expect( + client + .select({ id: schema.Project.id }) + .from(schema.Project) + .where(eq(schema.Project.hackathonId, targetHackathonId)), + ).resolves.toEqual([]); + await expect( + client + .select({ id: schema.ProjectClaim.memberId }) + .from(schema.ProjectClaim) + .where(eq(schema.ProjectClaim.hackathonId, targetHackathonId)), + ).resolves.toEqual([]); + await expect( + client + .select({ id: schema.JudgeDeliberationEntry.id }) + .from(schema.JudgeDeliberationEntry) + .where( + eq(schema.JudgeDeliberationEntry.hackathonId, targetHackathonId), + ), + ).resolves.toEqual([]); + await expect( + client + .select({ id: schema.JudgeDeliberationSection.id }) + .from(schema.JudgeDeliberationSection) + .where( + eq(schema.JudgeDeliberationSection.hackathonId, targetHackathonId), + ), + ).resolves.toEqual([{ id: targetSectionId }]); + await expect( + client + .select({ id: schema.ProjectChallenge.id }) + .from(schema.ProjectChallenge) + .where(eq(schema.ProjectChallenge.hackathonId, targetHackathonId)), + ).resolves.toEqual([{ id: targetGroupId }]); + const [configuration] = await client + .select({ + projectClaimUrl: schema.HackathonJudgingConfiguration.projectClaimUrl, + projectClaimsStartedAt: + schema.HackathonJudgingConfiguration.projectClaimsStartedAt, + projectInventoryLockedAt: + schema.HackathonJudgingConfiguration.projectInventoryLockedAt, + projectInventoryLockedByUserId: + schema.HackathonJudgingConfiguration.projectInventoryLockedByUserId, + }) + .from(schema.HackathonJudgingConfiguration) + .where( + eq( + schema.HackathonJudgingConfiguration.hackathonId, + targetHackathonId, + ), + ); + expect(configuration).toEqual({ + projectClaimUrl: null, + projectClaimsStartedAt: null, + projectInventoryLockedAt: null, + projectInventoryLockedByUserId: null, + }); + } finally { + await caller.judging.resetHackathon({ + confirmation: "Project Reset Hackathon", + hackathonId: targetHackathonId, + }); + await client + .delete(schema.Hackathon) + .where(eq(schema.Hackathon.id, targetHackathonId)); + } + }); + + it("resets the full hackathon while preserving starter configuration", async () => { + const preservedAuditId = randomUUID(); + await disposable.client.query( + `INSERT INTO audit_event + (id, action_key, domain, outcome, actor_user_id, actor_label) + VALUES ($1, 'judging.fixture', 'judging', 'committed', $2, 'Reset Officer')`, + [preservedAuditId, officerId], + ); + const roomCount = await client + .select() + .from(schema.JudgingRoom) + .where(eq(schema.JudgingRoom.hackathonId, hackathonId)); + await expect( + caller.judging.resetHackathon({ + confirmation: "wrong name", + hackathonId, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + caller.judging.resetHackathon({ + confirmation: "Reset Test Hackathon", + hackathonId, + }), + ).resolves.toMatchObject({ + evaluationCount: 1, + projectCount: 1, + roomCount: roomCount.length, + }); + for (const table of [ + "knight_hacks_guest_judge_session", + "knight_hacks_judge", + "knight_hacks_judge_deliberation_entry", + "knight_hacks_judge_deliberation_section", + "knight_hacks_judging_announcement", + "knight_hacks_judging_appointment", + "knight_hacks_judging_room", + "knight_hacks_judging_room_access_link", + "knight_hacks_judging_room_presence", + "knight_hacks_judging_rubric_item", + "knight_hacks_judging_schedule", + "knight_hacks_judging_schedule_job", + "knight_hacks_project", + "knight_hacks_project_claim", + "knight_hacks_project_claim_link", + "knight_hacks_project_evaluation", + "knight_hacks_project_evaluation_draft", + "knight_hacks_project_evaluation_rating", + "knight_hacks_project_evaluation_response", + "knight_hacks_project_evaluation_revision", + "knight_hacks_project_member", + "knight_hacks_project_to_challenge", + ]) { + const result = await disposable.client.query<{ count: string }>( + `SELECT count(*)::text AS count FROM ${table}`, + ); + expect(result.rows[0]?.count, table).toBe("0"); + } + const groups = await client + .select({ + isGroup: schema.ProjectChallenge.isGroup, + label: schema.ProjectChallenge.label, + }) + .from(schema.ProjectChallenge) + .where(eq(schema.ProjectChallenge.hackathonId, hackathonId)); + expect(groups).toEqual( + expect.arrayContaining([ + { isGroup: true, label: "General" }, + { isGroup: true, label: "MLH Challenges" }, + ]), + ); + const [configuration] = await client + .select() + .from(schema.HackathonJudgingConfiguration) + .where(eq(schema.HackathonJudgingConfiguration.hackathonId, hackathonId)); + expect(configuration).toMatchObject({ + displayAllResultsToMembers: false, + hackerScheduleEmergency: false, + hackerSchedulePublished: false, + projectClaimUrl: null, + projectClaimsStartedAt: null, + projectInventoryLockedAt: null, + state: "draft", + }); + await expect( + client + .select({ id: schema.Hackathon.id }) + .from(schema.Hackathon) + .where(eq(schema.Hackathon.id, hackathonId)), + ).resolves.toEqual([{ id: hackathonId }]); + await expect( + client + .select({ id: schema.JudgingBuilding.id }) + .from(schema.JudgingBuilding) + .where(eq(schema.JudgingBuilding.id, buildingId)), + ).resolves.toEqual([{ id: buildingId }]); + const preservedAudit = await disposable.client.query<{ id: string }>( + "SELECT id::text FROM audit_event WHERE id = $1", + [preservedAuditId], + ); + expect(preservedAudit.rows).toEqual([{ id: preservedAuditId }]); + }); +}); diff --git a/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap b/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap index cf5a3f48d..79d583c64 100644 --- a/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap +++ b/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap @@ -189,12 +189,14 @@ exports[`client-facing API surface > exposes exactly the documented procedure pa "hackathonEvent.voidAttendance", "hacker.awardPoints", "hacker.confirmBulk", + "hacker.confirmBulkDelete", "hacker.deleteApplication", "hacker.filterOptions", "hacker.get", "hacker.listForHackathon", "hacker.listHackathonOptions", "hacker.previewBulk", + "hacker.previewBulkDelete", "hacker.selectionSurvival", "hacker.setBlacklist", "hacker.setStatus", @@ -229,6 +231,9 @@ exports[`client-facing API surface > exposes exactly the documented procedure pa "judging.createDeliberationSection", "judging.createRoom", "judging.deleteDeliberationSection", + "judging.deleteRoom", + "judging.dropEvaluations", + "judging.dropRooms", "judging.dropSchedule", "judging.endGuest", "judging.generateRoomLink", @@ -264,6 +269,10 @@ exports[`client-facing API surface > exposes exactly the documented procedure pa "judging.renameDeliberationSection", "judging.reorderDeliberationProjects", "judging.reorderDeliberationSections", + "judging.resetHackathon", + "judging.resetLaunch", + "judging.resetProjects", + "judging.resetSetup", "judging.revokeGuest", "judging.revokeRoomLink", "judging.rotateRoomLink", diff --git a/packages/api/src/utils/audit/coverage.ts b/packages/api/src/utils/audit/coverage.ts index 0d6d64757..328ca4123 100644 --- a/packages/api/src/utils/audit/coverage.ts +++ b/packages/api/src/utils/audit/coverage.ts @@ -89,6 +89,7 @@ export const AUDITED_ADMIN_PROCEDURES = [ "hackathon-event.voidAttendance", "hacker.awardPoints", "hacker.confirmBulk", + "hacker.confirmBulkDelete", "hacker.deleteApplication", "hacker.setBlacklist", "hacker.updateProfile", @@ -104,6 +105,9 @@ export const AUDITED_ADMIN_PROCEDURES = [ "judging.archiveRoom", "judging.clearAnnouncement", "judging.createRoom", + "judging.deleteRoom", + "judging.dropEvaluations", + "judging.dropRooms", "project-challenges.updateChallenge", "project-challenges.createGroup", "project-challenges.updateGroup", @@ -113,6 +117,10 @@ export const AUDITED_ADMIN_PROCEDURES = [ "judging.provisionRoomThreads", "judging.publishAnnouncement", "judging.removeJudgeFromRoom", + "judging.resetHackathon", + "judging.resetLaunch", + "judging.resetProjects", + "judging.resetSetup", "judging.revokeGuest", "judging.revokeRoomLink", "judging.rotateRoomLink", @@ -238,6 +246,7 @@ export const EXCLUDED_ADMIN_PROCEDURES = [ "hacker.listForHackathon", "hacker.listHackathonOptions", "hacker.previewBulk", + "hacker.previewBulkDelete", "hacker.selectionSurvival", "hacker.statusCounts", "issues.get", diff --git a/packages/api/src/utils/hacker/access.ts b/packages/api/src/utils/hacker/access.ts index f9e2864cd..2184bf73e 100644 --- a/packages/api/src/utils/hacker/access.ts +++ b/packages/api/src/utils/hacker/access.ts @@ -1,6 +1,10 @@ import { TRPCError } from "@trpc/server"; -import type { HackerRosterFilter, SkipReason } from "@forge/validators"; +import type { + HackerBulkStatus, + HackerRosterFilter, + SkipReason, +} from "@forge/validators"; import { permissions } from "@forge/utils"; type PermissionContext = Parameters[1]; @@ -23,6 +27,19 @@ export function requireHackerEdit(ctx: PermissionContext) { return permissions.controlPerms.or(["EDIT_HACKERS"], ctx); } +export function requireHackerBulkEdit( + ctx: PermissionContext, + status: HackerBulkStatus, +) { + requireHackerEdit(ctx); + if (status === "checkedin" && ctx.session.permissions.IS_OFFICER !== true) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Officer permission is required to check in hackers.", + }); + } +} + export function redactHackerBlacklist< T extends { blacklistReason: string | null; blacklistedAt: Date | null }, >(row: T, ctx: PermissionContext) { diff --git a/packages/validators/src/audit.ts b/packages/validators/src/audit.ts index cee85fde5..9c7e58d9d 100644 --- a/packages/validators/src/audit.ts +++ b/packages/validators/src/audit.ts @@ -500,6 +500,30 @@ export const AUDIT_ACTION_CATALOG = { "projectId", "revision", ]), + "judging.evaluations.dropped": policy( + "hackathons", + "Dropped all judging evaluations", + ["draftCount", "evaluationCount"], + ), + "judging.rooms.dropped": policy("hackathons", "Dropped all judging rooms", [ + "roomCount", + ]), + "judging.room.deleted": policy("hackathons", "Deleted judging room"), + "judging.setup.reset": policy("hackathons", "Reset judging setup", [ + "groupCount", + "rubricItemCount", + ]), + "judging.launch.reset": policy("hackathons", "Reset judging launch", [ + "claimCount", + "claimLinkCount", + ]), + "judging.reset": policy("hackathons", "Reset hackathon judging", [ + "draftCount", + "evaluationCount", + "hadSchedule", + "projectCount", + "roomCount", + ]), "judging.schedule.generated": policy( "hackathons", "Generated judging schedule", @@ -511,6 +535,7 @@ export const AUDIT_ACTION_CATALOG = { "reducedBreakCount", ]), "judging.schedule.dropped": policy("hackathons", "Dropped judging schedule", [ + "jobCount", "scheduleId", ]), "judging.appointment.contacts_viewed": policy( @@ -753,6 +778,18 @@ export const AUDIT_ACTION_CATALOG = { "Deleted hackathon application", ["hackathonId", "legacySnapshotDeleted", "clearedCommandCount"], ), + "hacker.bulk_applications_deleted": policy( + "hackathons", + "Bulk deleted hackathon applications", + [ + "hackathonId", + "deletedCount", + "skippedCount", + "clearedCommandCount", + "legacySnapshotDeletedCount", + "subjectsTruncated", + ], + ), "hacker.application_withdrawn": policy( "hackathons", "Withdrew hackathon application", diff --git a/packages/validators/src/hackers.ts b/packages/validators/src/hackers.ts index 233f85969..00ca6cc45 100644 --- a/packages/validators/src/hackers.ts +++ b/packages/validators/src/hackers.ts @@ -5,27 +5,26 @@ import { FORMS } from "@forge/consts"; import { hackathonSendingStatusSchema } from "./hackathons"; import { nullableSocialProfileUrl } from "./social-profile"; -/** - * The statuses an officer can move an applicant to. - * - * Reused rather than re-derived, and that reuse is the point: - * `HACKATHON_SENDING_STATUSES` is `HACKATHON_APPLICATION_STATES` minus - * `checkedin`, so "checked-in is unreachable from the roster" is enforced by - * construction instead of by a second list someone has to remember to keep in - * step. Check-in belongs to the event slice and reaches the column another way. - */ +/** The statuses whose single-applicant transition sends mail. */ export const hackerTransitionStatusSchema = hackathonSendingStatusSchema; +/** Bulk check-in is the one roster transition that does not send mail. */ +export const hackerBulkStatusSchema = hackerTransitionStatusSchema.or( + z.literal("checkedin"), +); +export type HackerBulkStatus = z.infer; + /** * Officers see "capacity"; the stored value is `denied` and the applicant * receives the capacity template. Kept here so both the router and the screen * agree on the mapping rather than each spelling it out. */ export const HACKER_STATUS_LABELS: Record< - z.infer, + (typeof FORMS.HACKATHON_APPLICATION_STATES)[number], string > = { accepted: "Accepted", + checkedin: "Checked-In", confirmed: "Confirmed", denied: "Capacity", pending: "Applied", @@ -122,6 +121,16 @@ export const hackerDeleteApplicationSchema = z.object({ confirmed: z.literal(true), }); +const hackerBulkDeleteBaseSchema = z.object({ + attendeeIds: z.array(z.string().uuid()).min(1).max(5000), + hackathonId: z.string().uuid(), +}); + +export const hackerBulkDeletePreviewSchema = hackerBulkDeleteBaseSchema; +export const hackerBulkDeleteConfirmSchema = hackerBulkDeleteBaseSchema.extend({ + confirmed: z.literal(true), +}); + /** * A bulk action carries the ids the officer actually selected. * @@ -141,7 +150,7 @@ export const hackerDeleteApplicationSchema = z.object({ export const hackerBulkPreviewSchema = z.object({ attendeeIds: z.array(z.string().uuid()).min(1).max(5000), hackathonId: z.string().uuid(), - status: hackerTransitionStatusSchema, + status: hackerBulkStatusSchema, }); /** diff --git a/packages/validators/src/judging.ts b/packages/validators/src/judging.ts index d2f8e177d..af65e51a8 100644 --- a/packages/validators/src/judging.ts +++ b/packages/validators/src/judging.ts @@ -7,6 +7,10 @@ export const judgingHackathonIdSchema = z.object({ hackathonId: uuidSchema, }); +export const judgingDestructiveActionSchema = judgingHackathonIdSchema.extend({ + confirmation: z.string().min(1).max(255), +}); + export const judgingStateSchema = z.enum(["draft", "open", "closed"]); export const judgingRubricItemKindSchema = z.enum(["rating", "short_response"]); export const judgingResponseVisibilitySchema = z.enum([ @@ -170,6 +174,10 @@ export const judgingRoomIdSchema = z.object({ roomId: z.string().uuid(), }); +export const judgingRoomDeleteSchema = judgingRoomIdSchema.extend({ + confirmation: roomNameSchema, +}); + const discordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); export const judgingCommsChannelSchema = judgingHackathonIdSchema.extend({ diff --git a/packages/validators/src/tests/hackers.test.ts b/packages/validators/src/tests/hackers.test.ts index 5a4c81d0e..7e48523cf 100644 --- a/packages/validators/src/tests/hackers.test.ts +++ b/packages/validators/src/tests/hackers.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { HACKER_STATUS_LABELS, + hackerBulkDeleteConfirmSchema, + hackerBulkDeletePreviewSchema, hackerBulkPreviewSchema, hackerRosterListSchema, hackerSetBlacklistSchema, @@ -9,11 +11,8 @@ import { hackerUpdateProfileSchema, } from "../hackers"; -describe("TC-NEG-004: checked-in is unreachable from the roster", () => { - // The whole point of reusing `hackathonSendingStatusSchema` rather than the - // wider application-state list. Check-in belongs to the event slice and - // reaches the column another way; an officer must not be able to fake it. - it("rejects a transition to checkedin", () => { +describe("checked-in roster transitions", () => { + it("rejects checkedin for the single-applicant mail action", () => { expect( hackerSetStatusSchema.safeParse({ attendeeId: "00000000-0000-4000-8000-000000000001", @@ -22,6 +21,16 @@ describe("TC-NEG-004: checked-in is unreachable from the roster", () => { ).toBe(false); }); + it("allows checkedin for a bulk action", () => { + expect( + hackerBulkPreviewSchema.safeParse({ + attendeeIds: ["00000000-0000-4000-8000-000000000001"], + hackathonId: "00000000-0000-4000-8000-000000000002", + status: "checkedin", + }).success, + ).toBe(true); + }); + it.each([ "accepted", "confirmed", @@ -113,6 +122,29 @@ describe("bulk bounds", () => { }); }); +describe("bulk application deletion", () => { + const base = { + attendeeIds: ["00000000-0000-4000-8000-000000000001"], + hackathonId: "00000000-0000-4000-8000-000000000002", + }; + + it("previews a bounded selection", () => { + expect(hackerBulkDeletePreviewSchema.safeParse(base).success).toBe(true); + expect( + hackerBulkDeletePreviewSchema.safeParse({ ...base, attendeeIds: [] }) + .success, + ).toBe(false); + }); + + it("requires explicit confirmation", () => { + expect(hackerBulkDeleteConfirmSchema.safeParse(base).success).toBe(false); + expect( + hackerBulkDeleteConfirmSchema.safeParse({ ...base, confirmed: true }) + .success, + ).toBe(true); + }); +}); + describe("roster limit", () => { const hackathonId = "00000000-0000-4000-8000-000000000002"; @@ -143,17 +175,18 @@ describe("officer-facing status labels", () => { expect(HACKER_STATUS_LABELS.pending).toBe("Applied"); }); - it("labels every status an officer can set", () => { - // Missing a label renders a raw slug, so this pins the two lists together. - const settable = [ + it("labels every stored status", () => { + // Missing a label renders a blank badge, so this pins the two lists together. + const statuses = [ "accepted", + "checkedin", "confirmed", "denied", "pending", "waitlisted", "withdrawn", ]; - expect(Object.keys(HACKER_STATUS_LABELS).sort()).toEqual(settable.sort()); + expect(Object.keys(HACKER_STATUS_LABELS).sort()).toEqual(statuses.sort()); }); }); From 1e99571c61687097b59d5053c90e17e712a6b9fa Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sun, 13 Sep 2026 13:58:55 -0400 Subject: [PATCH 2/4] document judging operations PR Co-authored-by: Codex --- .forge/features/hacker-management/status.md | 2 ++ .forge/features/judging-command-center-reset/status.md | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.forge/features/hacker-management/status.md b/.forge/features/hacker-management/status.md index aee457d68..f5cda456f 100644 --- a/.forge/features/hacker-management/status.md +++ b/.forge/features/hacker-management/status.md @@ -30,6 +30,8 @@ the owner's final UI pass. and the hacker detail dialog; it is not represented as a roster status. - Final targeted validation passed: 178 API tests and 41 Blade tests. The full pre-commit gate and all 21 workspace build tasks passed. +- Issue: [#570](https://github.com/KnightHacks/forge/issues/570). PR: + [#571](https://github.com/KnightHacks/forge/pull/571). ## Delegated hacker permissions — 2026-09-06 diff --git a/.forge/features/judging-command-center-reset/status.md b/.forge/features/judging-command-center-reset/status.md index f3c3b3cfe..91c69b4bf 100644 --- a/.forge/features/judging-command-center-reset/status.md +++ b/.forge/features/judging-command-center-reset/status.md @@ -1,6 +1,6 @@ # Judging Command Center Reset Status -Current phase: Awaiting owner UI approval +Current phase: PR open; awaiting CI and review ## Decision log @@ -21,7 +21,7 @@ Current phase: Awaiting owner UI approval - [x] Implement reset procedures and initial command-center layout. - [x] Complete component, integration, and browser validation for judging scope. - [x] Complete final Forge review after the owner's layout refinements. -- [ ] Show desktop/mobile screenshots to the user before opening the PR. +- [x] Show desktop/mobile screenshots to the user before opening the PR. ## Validation / commands @@ -38,6 +38,6 @@ Current phase: Awaiting owner UI approval ## Links -- PRs: -- Issues: +- PRs: [#571](https://github.com/KnightHacks/forge/pull/571) +- Issues: [#570](https://github.com/KnightHacks/forge/issues/570) - Discord/thread context: From 541016bf6819efc6523da2e8a1a6cdf9c548bc3d Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sun, 13 Sep 2026 14:22:36 -0400 Subject: [PATCH 3/4] address judging operations review Co-authored-by: Codex --- .forge/features/hacker-management/spec.md | 3 +- .forge/features/hacker-management/srd.md | 20 ++-- .../features/hacker-management/test-cases.md | 4 +- .../judging-command-center-reset/srd.md | 1 + .../judging-command-center-reset/status.md | 1 + .../hackathon/hackers/bulk-confirm-dialog.tsx | 7 +- .../hackathon/hackers/hacker-filters.tsx | 11 ++- .../admin/hackathon/hackers/hacker-roster.tsx | 1 + .../judging/judging-reset-panel.tsx | 9 ++ .../judging/judging-schedule-panel.tsx | 2 +- .../tests/admin/bulk-confirm-dialog.test.ts | 3 +- .../tests/admin/hacker-filter-facets.test.ts | 6 ++ .../projects/judging-reset-panel.test.tsx | 16 +++ packages/api/src/routers/hacker.ts | 36 ++++++- packages/api/src/tests/hacker/access.test.ts | 1 + .../tests/integration/hacker-guards.test.ts | 98 +++++++++++++++++++ packages/validators/src/hackers.ts | 11 ++- packages/validators/src/tests/hackers.test.ts | 7 +- 18 files changed, 210 insertions(+), 27 deletions(-) diff --git a/.forge/features/hacker-management/spec.md b/.forge/features/hacker-management/spec.md index 8d2c2f100..6ede5de40 100644 --- a/.forge/features/hacker-management/spec.md +++ b/.forge/features/hacker-management/spec.md @@ -192,7 +192,8 @@ invisible to the applicant. - AC-019 A bulk action sends at most one mail per applicant. - AC-034 A Hacker Editor can permanently delete one application from its detail view or delete a selected group after previewing exactly who will be removed. - The roster and selection refresh after completion, and no email is sent. + Confirmation is rejected if that previewed deletion set changes. The roster + and selection refresh after completion, and no email is sent. - AC-035 A delegated Hacker Editor cannot delete a blacklisted application; that row is skipped in bulk without revealing its blacklist. Officers may delete it. diff --git a/.forge/features/hacker-management/srd.md b/.forge/features/hacker-management/srd.md index 50434a516..012e73091 100644 --- a/.forge/features/hacker-management/srd.md +++ b/.forge/features/hacker-management/srd.md @@ -199,16 +199,16 @@ the filtered path has to be as fast as the unfiltered one, and `school` and New `hacker` router, registered in `root.ts` as `api.hacker.*`. -| Procedure | Shape | Notes | -| ------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `listForHackathon` | query(`{hackathonId, search?, status?, cursor?}`) → page | Paginated. 2537 rows exist today; the largest hackathon must not be one read. | -| `statusCounts` | query(`{hackathonId}`) → count per status | One grouped query, not seven. | -| `setStatus` | mutation(`{attendeeId, status}`) | Enqueues the configured mail. Refuses on blacklist and on unconfigured. | -| `previewBulk` | mutation(`{hackathonId, attendeeIds, status}`) → who moves, who skips | Nothing is written. `checkedin` requires officer access and no mail config. | -| `confirmBulk` | mutation(`{hackathonId, attendeeIds, status}`) → per-hacker result | Re-resolves selected rows. `checkedin` records time and actor; no mail. | -| `previewBulkDelete` | mutation(`{hackathonId, attendeeIds}`) → who deletes, who skips | Nothing is written. Uses the existing blacklist permission rule. | -| `confirmBulkDelete` | mutation(`{hackathonId, attendeeIds, confirmed:true}`) → result | Hard-deletes in one transaction, clears commands, and sends no mail. | -| `setBlacklist` | mutation(`{attendeeId, blacklisted, reason?}`) | Never changes status. Sends nothing. | +| Procedure | Shape | Notes | +| ------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `listForHackathon` | query(`{hackathonId, filter?, cursor?, limit?}`) → page | Paginated. 2537 rows exist today; the largest hackathon must not be one read. | +| `statusCounts` | query(`{hackathonId, filter?}`) → count per status | One grouped query, not seven. | +| `setStatus` | mutation(`{attendeeId, status}`) | Enqueues the configured mail. Refuses on blacklist and on unconfigured. | +| `previewBulk` | mutation(`{hackathonId, attendeeIds, status}`) → who moves, who skips | Nothing is written. `checkedin` requires officer access and no mail config. | +| `confirmBulk` | mutation(`{hackathonId, attendeeIds, status}`) → per-hacker result | Re-resolves selected rows. `checkedin` records time and actor; no mail. | +| `previewBulkDelete` | mutation(`{hackathonId, attendeeIds}`) → who deletes, who skips | Nothing is written. Uses the existing blacklist permission rule. | +| `confirmBulkDelete` | mutation(`{hackathonId, attendeeIds, previewedAttendeeIds, confirmed:true}`) → result | Rejects a changed preview, hard-deletes in one transaction, clears commands, and sends no mail. | +| `setBlacklist` | mutation(`{attendeeId, blacklisted, reason?}`) | Never changes status. Sends nothing. | Errors: `NOT_FOUND` for unknown ids, `PRECONDITION_FAILED` for a blacklisted accept and for an unconfigured hackathon, `BAD_REQUEST` for validation. diff --git a/.forge/features/hacker-management/test-cases.md b/.forge/features/hacker-management/test-cases.md index bdb42b227..940eff10b 100644 --- a/.forge/features/hacker-management/test-cases.md +++ b/.forge/features/hacker-management/test-cases.md @@ -196,7 +196,9 @@ Expected: the preview names the ordinary applications, confirmation removes their attendee rows and participant commands, orphaned legacy Hacker snapshots are removed, reusable profiles remain, and no email send is created. The editor sees the blacklisted row as an undisclosed skip; an officer may delete it. Blade -states that deletion is permanent and sends no email. +states that deletion is permanent and sends no email. If a skipped application +becomes eligible after preview, confirmation rejects the changed set and deletes +nothing until the editor reviews it again. ### TC-015: The selection is amendable (AC-027) diff --git a/.forge/features/judging-command-center-reset/srd.md b/.forge/features/judging-command-center-reset/srd.md index 78ece3dd7..97e29ecac 100644 --- a/.forge/features/judging-command-center-reset/srd.md +++ b/.forge/features/judging-command-center-reset/srd.md @@ -26,6 +26,7 @@ Every reset/delete procedure uses `permProcedure` and `assertCanManageProjects`. - `judging.dropEvaluations`: deletes drafts/evaluations and clears `firstResultAt` so the saved schedule can be dropped again. - `judging.dropRooms`: deletes all rooms and room-scoped access data after the schedule is gone. +- `judging.resetProjects`: deletes deliberation entries, claims, projects, and non-group challenges, and clears inventory-lock metadata after downstream judging data is gone. - `judging.resetSetup`: restores starter groups and clears the rubric after downstream rooms, schedules, and feedback are gone. - `judging.resetLaunch`: clears claims/claim links and restores publication, visibility, and judging-state defaults. - `judging.resetHackathon`: performs the full reset atomically. diff --git a/.forge/features/judging-command-center-reset/status.md b/.forge/features/judging-command-center-reset/status.md index 91c69b4bf..4d9f63067 100644 --- a/.forge/features/judging-command-center-reset/status.md +++ b/.forge/features/judging-command-center-reset/status.md @@ -9,6 +9,7 @@ Current phase: PR open; awaiting CI and review - 2026-09-13: Routine alerts must not occupy document flow; urgent announcements remain blocking dialogs. - 2026-09-13: Project inventory, visibility, import, deletion, search, and filters share one compact control surface. - 2026-09-13: Every granular destructive action is a direct Reset action; navigation-only tiles were removed. +- 2026-09-13: The owner approved the revised layout and requested PR publication after reviewing the desktop and mobile captures. ## Open questions diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx index 2fc2e185d..0ecc07ac0 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx @@ -23,7 +23,7 @@ export type HackerBulkAction = HackerBulkStatus | "delete"; export function bulkActionCopy(action: HackerBulkAction, count: number) { if (action === "delete") { return { - confirm: `Delete ${count} applications`, + confirm: `Delete ${count} application${count === 1 ? "" : "s"}`, description: "This permanently removes their applications and hackathon activity. Their accounts and reusable profiles remain. No email is sent.", label: "Will be permanently deleted", @@ -115,10 +115,10 @@ export function BulkConfirmDialog({ }); const statusConfirm = api.hacker.confirmBulk.useMutation({ onError: (error) => toast.error(error.message), - onSuccess: (result) => { + onSuccess: (result, variables) => { const skippedNote = result.skipped.length > 0 ? `, ${result.skipped.length} skipped` : ""; - if (action === "checkedin") { + if (variables.status === "checkedin") { toast.success( `${result.movedCount} checked in${skippedNote}. No email sent.`, ); @@ -323,6 +323,7 @@ export function BulkConfirmDialog({ attendeeIds, confirmed: true, hackathonId, + previewedAttendeeIds: affected.map((row) => row.attendeeId), }); } else if (action) { statusConfirm.mutate({ diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-filters.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-filters.tsx index e4e567891..789f68b42 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-filters.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/hacker-filters.tsx @@ -659,15 +659,24 @@ export const HACKER_STATUS_FILTERS = [ "withdrawn", ] as const; +/** Checked-In attendance stays on the officer surface. */ +export function hackerStatusFiltersFor(isOfficer: boolean) { + return HACKER_STATUS_FILTERS.filter( + (status) => isOfficer || status !== "checkedin", + ); +} + export function StatusTabs({ busy, counts, filter, + isOfficer, onFilterChange, }: { busy: boolean; counts: RouterOutputs["hacker"]["statusCounts"]; filter: RosterFilter; + isOfficer: boolean; onFilterChange: (patch: RosterFilterPatch) => void; }) { return ( @@ -679,7 +688,7 @@ export function StatusTabs({ label="All" onClick={() => onFilterChange({ status: undefined })} /> - {HACKER_STATUS_FILTERS.map((status) => ( + {hackerStatusFiltersFor(isOfficer).map((status) => ( void requestFilter(patch)} /> )} diff --git a/apps/blade/src/app/_components/judging/judging-reset-panel.tsx b/apps/blade/src/app/_components/judging/judging-reset-panel.tsx index 074da0fe5..1b8740536 100644 --- a/apps/blade/src/app/_components/judging/judging-reset-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-reset-panel.tsx @@ -216,6 +216,7 @@ export function DropEvaluationsButton({ export function JudgingResetPanel({ data }: { data: ControlData }) { const router = useRouter(); + const utils = api.useUtils(); const [action, setAction] = useState(null); const evaluations = api.judging.dropEvaluations.useMutation(); const projects = api.judging.resetProjects.useMutation(); @@ -252,6 +253,14 @@ export function JudgingResetPanel({ data }: { data: ControlData }) { : `${actionCopy[action].button} complete.`, ); setAction(null); + await Promise.all([ + utils.judging.listAdmin.invalidate({ + hackathonId: data.hackathon.id, + }), + utils.judging.listScheduleAdmin.invalidate({ + hackathonId: data.hackathon.id, + }), + ]); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Reset failed."); diff --git a/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx b/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx index b87e7dcbd..0b37a28ee 100644 --- a/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-schedule-panel.tsx @@ -199,7 +199,7 @@ export function JudgingSchedulePanel({ ? job : null; const alertKey = query.error - ? `refresh:${query.error.message}` + ? `refresh:${query.dataUpdatedAt}:${query.error.message}` : failedJob ? `job:${failedJob.id}:${failedJob.status}` : null; diff --git a/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts b/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts index 18ac02659..61f447323 100644 --- a/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts +++ b/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts @@ -93,12 +93,13 @@ describe("bulk hacker deletion", () => { expect(mutations.previewStatus).not.toHaveBeenCalled(); await user.click( - screen.getByRole("button", { name: "Delete 1 applications" }), + screen.getByRole("button", { name: "Delete 1 application" }), ); expect(mutations.confirmDelete).toHaveBeenCalledWith({ attendeeIds: ["attendee-1"], confirmed: true, hackathonId: "hackathon-1", + previewedAttendeeIds: ["attendee-1"], }); expect(mutations.confirmStatus).not.toHaveBeenCalled(); }); diff --git a/apps/blade/src/tests/admin/hacker-filter-facets.test.ts b/apps/blade/src/tests/admin/hacker-filter-facets.test.ts index c83696929..0169bf57f 100644 --- a/apps/blade/src/tests/admin/hacker-filter-facets.test.ts +++ b/apps/blade/src/tests/admin/hacker-filter-facets.test.ts @@ -3,12 +3,18 @@ import { describe, expect, it } from "vitest"; import { changedFacets, HACKER_STATUS_FILTERS, + hackerStatusFiltersFor, } from "~/app/_components/admin/hackathon/hackers/hacker-filters"; describe("hacker status filters", () => { it("includes checked-in applicants", () => { expect(HACKER_STATUS_FILTERS).toContain("checkedin"); }); + + it("shows Checked-In only to officers", () => { + expect(hackerStatusFiltersFor(false)).not.toContain("checkedin"); + expect(hackerStatusFiltersFor(true)).toContain("checkedin"); + }); }); /** diff --git a/apps/blade/src/tests/projects/judging-reset-panel.test.tsx b/apps/blade/src/tests/projects/judging-reset-panel.test.tsx index 7923b6c2e..55eb0431b 100644 --- a/apps/blade/src/tests/projects/judging-reset-panel.test.tsx +++ b/apps/blade/src/tests/projects/judging-reset-panel.test.tsx @@ -17,12 +17,22 @@ const mutations = vi.hoisted(() => ({ resetProjects: vi.fn().mockResolvedValue({}), resetSetup: vi.fn().mockResolvedValue({}), })); +const invalidations = vi.hoisted(() => ({ + listAdmin: vi.fn().mockResolvedValue(undefined), + listScheduleAdmin: vi.fn().mockResolvedValue(undefined), +})); vi.mock("~/app/_components/shared/route-transition-link", () => ({ useNavigationRouter: () => ({ refresh: mutations.refresh }), })); vi.mock("~/trpc/react", () => ({ api: { + useUtils: () => ({ + judging: { + listAdmin: { invalidate: invalidations.listAdmin }, + listScheduleAdmin: { invalidate: invalidations.listScheduleAdmin }, + }, + }), judging: { dropEvaluations: { useMutation: () => mutation(mutations.dropEvaluations), @@ -131,6 +141,12 @@ describe("judging reset actions", () => { .filter((candidate) => candidate !== mutations.refresh) .reduce((total, candidate) => total + candidate.mock.calls.length, 0), ).toBe(1); + expect(invalidations.listAdmin).toHaveBeenCalledWith({ + hackathonId: "hackathon-1", + }); + expect(invalidations.listScheduleAdmin).toHaveBeenCalledWith({ + hackathonId: "hackathon-1", + }); }, ); diff --git a/packages/api/src/routers/hacker.ts b/packages/api/src/routers/hacker.ts index ef7e43c5e..0129437bd 100644 --- a/packages/api/src/routers/hacker.ts +++ b/packages/api/src/routers/hacker.ts @@ -15,6 +15,7 @@ import { inArray, isNotNull, isNull, + ne, or, sql, } from "@forge/db"; @@ -562,7 +563,14 @@ export const hackerRouter = createTRPCRouter({ // Left, not inner: an application can outlive the account that made it. .leftJoin(User, eq(User.id, Hacker.userId)) .leftJoin(EmailSend, eq(EmailSend.id, HackerAttendee.lastStatusSendId)) - .where(eq(HackerAttendee.id, input.attendeeId)) + .where( + and( + eq(HackerAttendee.id, input.attendeeId), + ctx.session.permissions.IS_OFFICER + ? undefined + : ne(HackerAttendee.status, "checkedin"), + ), + ) .limit(1); if (!row) { @@ -597,6 +605,9 @@ export const hackerRouter = createTRPCRouter({ .where( and( rosterWhere(input.hackathonId, input.filter), + ctx.session.permissions.IS_OFFICER + ? undefined + : ne(HackerAttendee.status, "checkedin"), cursorAfter(input.cursor), ), ) @@ -636,7 +647,14 @@ export const hackerRouter = createTRPCRouter({ .from(HackerAttendee) .innerJoin(Hacker, eq(Hacker.id, HackerAttendee.hackerId)) .leftJoin(EmailSend, eq(EmailSend.id, HackerAttendee.lastStatusSendId)) - .where(rosterWhere(input.hackathonId, countable)) + .where( + and( + rosterWhere(input.hackathonId, countable), + ctx.session.permissions.IS_OFFICER + ? undefined + : ne(HackerAttendee.status, "checkedin"), + ), + ) .groupBy(HackerAttendee.status); return { @@ -663,6 +681,9 @@ export const hackerRouter = createTRPCRouter({ const rows = await rosterQuery().where( and( rosterWhere(input.hackathonId, input.filter), + ctx.session.permissions.IS_OFFICER + ? undefined + : ne(HackerAttendee.status, "checkedin"), inArray(HackerAttendee.id, input.attendeeIds), ), ); @@ -1024,6 +1045,17 @@ export const hackerRouter = createTRPCRouter({ ctx.session.permissions.IS_OFFICER === true, true, ); + const previewed = new Set(input.previewedAttendeeIds); + if ( + deleting.length !== previewed.size || + deleting.some((row) => !previewed.has(row.attendeeId)) + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "The deletion set changed after preview. Review it again before deleting.", + }); + } if (deleting.length === 0) { return { deletedCount: 0, diff --git a/packages/api/src/tests/hacker/access.test.ts b/packages/api/src/tests/hacker/access.test.ts index 27a0679d1..5b5fe8fa6 100644 --- a/packages/api/src/tests/hacker/access.test.ts +++ b/packages/api/src/tests/hacker/access.test.ts @@ -128,6 +128,7 @@ const PROCEDURES: [string, (caller: Caller) => Promise][] = [ attendeeIds: [ATTENDEE_ID], confirmed: true, hackathonId: HACKATHON_ID, + previewedAttendeeIds: [ATTENDEE_ID], }), ], [ diff --git a/packages/api/src/tests/integration/hacker-guards.test.ts b/packages/api/src/tests/integration/hacker-guards.test.ts index 49b433f94..5c9bd83bf 100644 --- a/packages/api/src/tests/integration/hacker-guards.test.ts +++ b/packages/api/src/tests/integration/hacker-guards.test.ts @@ -421,6 +421,49 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { }, ); + it("keeps Checked-In applicants officer-only in roster reads", async () => { + await client + .update(knightHacks.HackerAttendee) + .set({ status: "checkedin" }) + .where(eq(knightHacks.HackerAttendee.id, PLAIN_ATTENDEE)); + await client + .update(auth.Roles) + .set({ permissions: permissionBitstring("READ_HACKERS") }) + .where(eq(auth.Roles.id, OFFICER_ROLE)); + + const roster = await caller.hacker.listForHackathon({ + hackathonId: READY_HACKATHON, + }); + expect(roster.hackers.map((row) => row.attendeeId)).not.toContain( + PLAIN_ATTENDEE, + ); + await expect( + caller.hacker.statusCounts({ hackathonId: READY_HACKATHON }), + ).resolves.toMatchObject({ total: 2 }); + await expect( + caller.hacker.listForHackathon({ + filter: { status: "checkedin" }, + hackathonId: READY_HACKATHON, + }), + ).resolves.toMatchObject({ hackers: [] }); + await expect( + caller.hacker.get({ attendeeId: PLAIN_ATTENDEE }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + await client + .update(auth.Roles) + .set({ permissions: permissionBitstring("IS_OFFICER") }) + .where(eq(auth.Roles.id, OFFICER_ROLE)); + await expect( + caller.hacker.listForHackathon({ + filter: { status: "checkedin" }, + hackathonId: READY_HACKATHON, + }), + ).resolves.toMatchObject({ + hackers: [expect.objectContaining({ attendeeId: PLAIN_ATTENDEE })], + }); + }); + it("keeps blacklist details available to officers", async () => { await expect( caller.hacker.get({ attendeeId: BLACKLISTED_ATTENDEE }), @@ -534,6 +577,7 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { attendeeIds: [BLACKLISTED_ATTENDEE], confirmed: true, hackathonId: READY_HACKATHON, + previewedAttendeeIds: [], }), ).resolves.toMatchObject({ deletedCount: 0 }); const blocked = await client.query.HackerAttendee.findFirst({ @@ -1619,6 +1663,7 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { attendeeIds, confirmed: true, hackathonId: READY_HACKATHON, + previewedAttendeeIds: attendeeIds, }), ).resolves.toEqual({ deletedCount: 2, skipped: [] }); @@ -1685,6 +1730,58 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { } }); + it("rejects deletion when the eligible set changes after preview", async () => { + const deleting = await createDeleteFixture(2); + const initiallySkipped = await createDeleteFixture(3, true); + const attendeeIds = [deleting.attendeeId, initiallySkipped.attendeeId]; + + try { + await client + .update(auth.Roles) + .set({ permissions: permissionBitstring("EDIT_HACKERS") }) + .where(eq(auth.Roles.id, OFFICER_ROLE)); + const preview = await caller.hacker.previewBulkDelete({ + attendeeIds, + hackathonId: READY_HACKATHON, + }); + expect(preview.deleting.map((row) => row.attendeeId)).toEqual([ + deleting.attendeeId, + ]); + + await client + .update(knightHacks.HackerAttendee) + .set({ + blacklistReason: null, + blacklistedAt: null, + blacklistedBy: null, + }) + .where( + eq(knightHacks.HackerAttendee.id, initiallySkipped.attendeeId), + ); + + await expect( + caller.hacker.confirmBulkDelete({ + attendeeIds, + confirmed: true, + hackathonId: READY_HACKATHON, + previewedAttendeeIds: preview.deleting.map((row) => row.attendeeId), + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + await expect( + client + .select({ id: knightHacks.HackerAttendee.id }) + .from(knightHacks.HackerAttendee) + .where(inArray(knightHacks.HackerAttendee.id, attendeeIds)), + ).resolves.toHaveLength(2); + } finally { + await client + .delete(auth.User) + .where( + inArray(auth.User.id, [deleting.userId, initiallySkipped.userId]), + ); + } + }); + it("allows an officer to delete a blacklisted application", async () => { const fixture = await createDeleteFixture(4, true); @@ -1705,6 +1802,7 @@ describe.skipIf(!canRunDatabaseTests())("hacker management guards", () => { attendeeIds: [fixture.attendeeId], confirmed: true, hackathonId: READY_HACKATHON, + previewedAttendeeIds: [fixture.attendeeId], }), ).resolves.toEqual({ deletedCount: 1, skipped: [] }); await expect( diff --git a/packages/validators/src/hackers.ts b/packages/validators/src/hackers.ts index 00ca6cc45..6d13ed567 100644 --- a/packages/validators/src/hackers.ts +++ b/packages/validators/src/hackers.ts @@ -129,15 +129,16 @@ const hackerBulkDeleteBaseSchema = z.object({ export const hackerBulkDeletePreviewSchema = hackerBulkDeleteBaseSchema; export const hackerBulkDeleteConfirmSchema = hackerBulkDeleteBaseSchema.extend({ confirmed: z.literal(true), + previewedAttendeeIds: z.array(z.string().uuid()).max(5000), }); /** - * A bulk action carries the ids the officer actually selected. + * A bulk action carries the ids the officer actually selected. Destructive + * confirmation also carries the eligible ids shown by preview; the API locks + * and re-resolves the selection, then rejects if that set changed. * - * Preview and confirm take the same shape on purpose. There is no stored - * preview handle: the ids are the selection, so a persisted snapshot would add - * a lifecycle to manage and would act on eligibility frozen at preview time. - * Re-resolving at confirm catches anyone blacklisted in between and names them. + * There is no stored preview handle: the compared ids bind confirmation to the + * visible preview without adding a persisted snapshot lifecycle. * * Not a filter: filtering the table and selecting across it already *is* * selecting by filter, and resolving a filter server-side at confirm time diff --git a/packages/validators/src/tests/hackers.test.ts b/packages/validators/src/tests/hackers.test.ts index 7e48523cf..fb8b51f6e 100644 --- a/packages/validators/src/tests/hackers.test.ts +++ b/packages/validators/src/tests/hackers.test.ts @@ -139,8 +139,11 @@ describe("bulk application deletion", () => { it("requires explicit confirmation", () => { expect(hackerBulkDeleteConfirmSchema.safeParse(base).success).toBe(false); expect( - hackerBulkDeleteConfirmSchema.safeParse({ ...base, confirmed: true }) - .success, + hackerBulkDeleteConfirmSchema.safeParse({ + ...base, + confirmed: true, + previewedAttendeeIds: base.attendeeIds, + }).success, ).toBe(true); }); }); From 2034640771b04c4cfb819e66036a2ce8e51cfce9 Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sun, 13 Sep 2026 15:54:57 -0400 Subject: [PATCH 4/4] address final judging review notes Co-authored-by: Codex --- .forge/features/hacker-management/srd.md | 8 +++++--- .../admin/hackathon/hackers/bulk-confirm-dialog.tsx | 5 ++++- apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.forge/features/hacker-management/srd.md b/.forge/features/hacker-management/srd.md index 012e73091..6a33aea40 100644 --- a/.forge/features/hacker-management/srd.md +++ b/.forge/features/hacker-management/srd.md @@ -132,9 +132,11 @@ because of how it is selected, not because of a separate feature: Several hundred uuids in a `previewBulk` payload is unremarkable, and the snapshot semantics still earn their keep: something can change between preview and confirm — an applicant gets blacklisted, or withdraws on the hack site — so -`previewBulk` still returns a `previewVersion` and `confirmBulk` still reports -anything that moved rather than silently including or dropping it. That is -AC-029, and it holds regardless of how the selection was made. +`confirmBulk` re-resolves the selected rows and reports anything that moved +rather than silently including or dropping it. Deletion is stricter: +`confirmBulkDelete` rejects the request if its eligible ids differ from the +previewed ids. That is AC-029, and it holds regardless of how the selection was +made. **No custom pointer machinery.** An earlier draft made click-and-drag a requirement and flagged it as the risky part. It is not required: a shift-click diff --git a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx index 0ecc07ac0..82d343210 100644 --- a/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/hackers/bulk-confirm-dialog.tsx @@ -213,6 +213,7 @@ export function BulkConfirmDialog({ const skipped = result?.skipped ?? []; const hasResult = result !== undefined; const copy = action ? bulkActionCopy(action, affected.length) : null; + const subject = deleting ? "application" : "applicant"; const confirmPending = deleting ? deleteConfirm.isPending : statusConfirm.isPending; @@ -223,7 +224,9 @@ export function BulkConfirmDialog({ {copy?.title ?? ""}{" "} - {hasResult ? `${affected.length} applicants` : "applicants…"} + {hasResult + ? `${affected.length} ${subject}${affected.length === 1 ? "" : "s"}` + : `${subject}s…`} {copy?.description ?? "Preparing this bulk action."} diff --git a/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts b/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts index 61f447323..a49cb8fe8 100644 --- a/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts +++ b/apps/blade/src/tests/admin/bulk-confirm-dialog.test.ts @@ -91,6 +91,9 @@ describe("bulk hacker deletion", () => { hackathonId: "hackathon-1", }); expect(mutations.previewStatus).not.toHaveBeenCalled(); + expect( + screen.getByRole("heading", { name: "Delete 1 application" }), + ).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: "Delete 1 application" }),