diff --git a/.forge/features/hacker-management/spec.md b/.forge/features/hacker-management/spec.md index c795d345d..6ede5de40 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,20 @@ 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. + 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. **Honesty** diff --git a/.forge/features/hacker-management/srd.md b/.forge/features/hacker-management/srd.md index ed71bd50c..6a33aea40 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; @@ -129,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 @@ -196,14 +201,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, 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. @@ -216,11 +223,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 +253,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 +296,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..f5cda456f 100644 --- a/.forge/features/hacker-management/status.md +++ b/.forge/features/hacker-management/status.md @@ -1,7 +1,37 @@ # 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. +- Issue: [#570](https://github.com/KnightHacks/forge/issues/570). PR: + [#571](https://github.com/KnightHacks/forge/pull/571). ## Delegated hacker permissions — 2026-09-06 @@ -50,8 +80,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 +165,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..940eff10b 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,22 @@ 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. 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) Setup: a filtered roster spanning more than one page. @@ -313,15 +330,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..97e29ecac --- /dev/null +++ b/.forge/features/judging-command-center-reset/srd.md @@ -0,0 +1,63 @@ +# 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.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. +- `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..4d9f63067 --- /dev/null +++ b/.forge/features/judging-command-center-reset/status.md @@ -0,0 +1,44 @@ +# Judging Command Center Reset Status + +Current phase: PR open; awaiting CI and review + +## 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. +- 2026-09-13: The owner approved the revised layout and requested PR publication after reviewing the desktop and mobile captures. + +## 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. +- [x] 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: [#571](https://github.com/KnightHacks/forge/pull/571) +- Issues: [#570](https://github.com/KnightHacks/forge/issues/570) +- 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..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 @@ -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} 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", + 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 confirm = api.hacker.confirmBulk.useMutation({ + const deletePreview = api.hacker.previewBulkDelete.useMutation({ onError: (error) => toast.error(error.message), - onSuccess: (result) => { + }); + const statusConfirm = api.hacker.confirmBulk.useMutation({ + onError: (error) => toast.error(error.message), + onSuccess: (result, variables) => { const skippedNote = result.skipped.length > 0 ? `, ${result.skipped.length} skipped` : ""; + if (variables.status === "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,76 @@ 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 subject = deleting ? "application" : "applicant"; + const confirmPending = deleting + ? deleteConfirm.isPending + : statusConfirm.isPending; return ( - {status ? HACKER_STATUS_LABELS[status] : ""}{" "} - {result ? `${result.sending.length} applicants` : "applicants…"} + {copy?.title ?? ""}{" "} + {hasResult + ? `${affected.length} ${subject}${affected.length === 1 ? "" : "s"}` + : `${subject}s…`} - 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..1b8740536 --- /dev/null +++ b/apps/blade/src/app/_components/judging/judging-reset-panel.tsx @@ -0,0 +1,386 @@ +"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 utils = api.useUtils(); + 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); + 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."); + } + } + + 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..0b37a28ee 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.dataUpdatedAt}:${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(); + expect( + screen.getByRole("heading", { name: "Delete 1 application" }), + ).toBeInTheDocument(); + + await user.click( + 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(); + }); +}); 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..0169bf57f 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,21 @@ import { describe, expect, it } from "vitest"; -import { changedFacets } from "~/app/_components/admin/hackathon/hackers/hacker-filters"; +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"); + }); +}); /** * 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..55eb0431b --- /dev/null +++ b/apps/blade/src/tests/projects/judging-reset-panel.test.tsx @@ -0,0 +1,176 @@ +/** @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({}), +})); +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), + }, + 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); + expect(invalidations.listAdmin).toHaveBeenCalledWith({ + hackathonId: "hackathon-1", + }); + expect(invalidations.listScheduleAdmin).toHaveBeenCalledWith({ + hackathonId: "hackathon-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..0129437bd 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, @@ -11,6 +15,7 @@ import { inArray, isNotNull, isNull, + ne, or, sql, } from "@forge/db"; @@ -27,6 +32,8 @@ import { import { HACKATHON_SENDING_STATUSES, hackerAwardPointsSchema, + hackerBulkDeleteConfirmSchema, + hackerBulkDeletePreviewSchema, hackerBulkPreviewSchema, hackerDeleteApplicationSchema, hackerFilterOptionsSchema, @@ -49,6 +56,7 @@ import { getDiscordEngagement } from "../utils/discord/engagement"; import { redactHackerBlacklist, redactHackerSkipReasons, + requireHackerBulkEdit, requireHackerEdit, requireHackerRead, } from "../utils/hacker/access"; @@ -376,6 +384,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. @@ -540,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) { @@ -575,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), ), ) @@ -614,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 { @@ -641,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), ), ); @@ -806,22 +849,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 +900,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 +927,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 +986,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 +1003,90 @@ 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, + ); + 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, + 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 +1327,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 +1356,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 +1398,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 +1432,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 +1496,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 +1541,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..5b5fe8fa6 100644 --- a/packages/api/src/tests/hacker/access.test.ts +++ b/packages/api/src/tests/hacker/access.test.ts @@ -113,6 +113,24 @@ 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, + previewedAttendeeIds: [ATTENDEE_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..5c9bd83bf 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", }) @@ -414,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 }), @@ -447,6 +497,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 +557,29 @@ 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, + previewedAttendeeIds: [], + }), + ).resolves.toMatchObject({ deletedCount: 0 }); const blocked = await client.query.HackerAttendee.findFirst({ where: eq(knightHacks.HackerAttendee.id, BLACKLISTED_ATTENDEE), }); @@ -618,6 +708,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 +1551,271 @@ 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, + previewedAttendeeIds: attendeeIds, + }), + ).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("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); + + 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, + previewedAttendeeIds: [fixture.attendeeId], + }), + ).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..6d13ed567 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,13 +121,24 @@ 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), + 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 @@ -141,7 +151,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..fb8b51f6e 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,32 @@ 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, + previewedAttendeeIds: base.attendeeIds, + }).success, + ).toBe(true); + }); +}); + describe("roster limit", () => { const hackathonId = "00000000-0000-4000-8000-000000000002"; @@ -143,17 +178,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()); }); });