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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions .forge/features/hacker-management/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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".
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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**

Expand Down
61 changes: 38 additions & 23 deletions .forge/features/hacker-management/srd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions .forge/features/hacker-management/status.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.**
Expand Down Expand Up @@ -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
Expand Down
36 changes: 28 additions & 8 deletions .forge/features/hacker-management/test-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading