From 9d67fed779251d193d49472e74dfef3d02e0721b Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sat, 5 Sep 2026 16:54:19 -0400 Subject: [PATCH 1/5] started configurable judging scores --- .../judging-scores-and-deliberation/spec.md | 136 +++++++ .../judging-scores-and-deliberation/srd.md | 224 +++++++++++ .../judging-scores-and-deliberation/status.md | 58 +++ .../test-cases.md | 351 ++++++++++++++++++ packages/db/src/schemas/knight-hacks.ts | 260 +++++++++++++ packages/validators/src/judging.ts | 114 +++++- packages/validators/src/tests/judging.test.ts | 98 +++++ 7 files changed, 1240 insertions(+), 1 deletion(-) create mode 100644 .forge/features/judging-scores-and-deliberation/spec.md create mode 100644 .forge/features/judging-scores-and-deliberation/srd.md create mode 100644 .forge/features/judging-scores-and-deliberation/status.md create mode 100644 .forge/features/judging-scores-and-deliberation/test-cases.md diff --git a/.forge/features/judging-scores-and-deliberation/spec.md b/.forge/features/judging-scores-and-deliberation/spec.md new file mode 100644 index 000000000..fb996de05 --- /dev/null +++ b/.forge/features/judging-scores-and-deliberation/spec.md @@ -0,0 +1,136 @@ +# Judging scores and deliberation spec + +Status: Approved + +## User-facing purpose + +Judges need one place to find projects, record scores and feedback, correct their own submissions, and organize finalists before deliberation. Officers need to configure that experience without a code change each year. + +This feature turns the existing project directory into the judging workspace while preserving the restricted guest flow added by `judging-magic-access`. + +## Users and actors + +- Guest judge: a sponsor or invited judge using a room QR session. They remain locked to the room's challenge. +- Member judge: a signed-in Blade user whose role grants judge access. They can change challenge filters and optionally join a room. +- Officer: a signed-in Blade user with `IS_OFFICER`. Officers configure judging, import projects, manage rooms, and control result visibility. + +## User-visible interface + +### Judge workspace + +`/judge/projects` has three tabs. + +- `Projects` keeps the searchable project table as the primary judging view. It adds the selected challenge's rating and a judging action to each project. +- `Submissions` lists the current judge's evaluations, feedback, score, challenge, and last edit time. A judge can open and edit an evaluation while judging is open. +- `Deliberation` explains that private sections help a judge compare projects before award discussions. Judges can create, rename, reorder, and delete sections, add projects they have judged, and drag projects into their preferred order. + +The URL stores the active tab and member challenge filter so refresh, back, and forward navigation preserve the workspace. + +### Evaluation form + +The project evaluation opens in a viewport-safe dialog or mobile drawer. It shows the project and challenge before the questions. + +- Each quantitative rubric item uses an integer scale from 1 through 5. +- The rubric may contain any number of quantitative items. +- The rubric may contain short-response items. +- The form explains who can read each short response before the judge submits it. +- If a guest response is optional-public, the guest chooses whether officers and authenticated judges may read it. It remains private to that guest otherwise. +- Authenticated member-judge responses are public to the judging team. + +A saved evaluation closes the form, updates the row, and appears in `Submissions`. Editing replaces the current response while retaining its revision history for officers. + +### Scores + +For a project in a selected challenge: + +- A judge sees `(?)` until they have evaluated that project in that challenge. +- After they submit, they see the average score from all evaluations for that project and challenge. +- An officer can enable `Display all results` so authenticated member judges see available scoped results before submitting. The switch never expands guest access. +- Guest judges only receive scores for their room's challenge. +- Authenticated member judges may also see an `Overall rating` column. It averages every evaluation for the project across all challenge scopes. + +Empty aggregates display `(?)`, not zero. + +### Project command center + +The officer judging page becomes the project command center. It combines the current project import and room controls with: + +- judging state: `Draft`, `Open`, or `Closed`; +- rubric setup and ordering; +- the `Display all results` switch for authenticated member judges; +- project inventory and add-only Devpost import; +- room provisioning, QR creation and revocation, and the live room roster. + +The existing project-admin URL remains usable and leads to the projects section of the command center. + +### Judging state + +- `Draft`: officers configure the rubric and inventory. Judges can browse projects but cannot submit evaluations. +- `Open`: judges can create and edit evaluations and manage deliberation lists. +- `Closed`: evaluations and deliberation lists are read-only. Officers can reopen judging. + +The rubric requires at least one quantitative item before judging opens. Once the first evaluation exists, officers cannot change the rubric or replace the imported inventory. + +## Scope + +### In scope + +- Officer-configured, hackathon-specific judging rubrics. +- Any number of 1 through 5 quantitative questions and short-response questions. +- Guest and member evaluation creation with server-enforced challenge scope. +- One editable evaluation per judge, project, and challenge. +- Personal submissions history. +- Scoped and overall score calculation and display rules. +- Personal deliberation sections with pointer and keyboard reordering. +- Draft, Open, and Closed judging states. +- Result visibility control for authenticated member judges. +- A combined project command center for projects, rubric, judging controls, and room operations. +- Import and deletion safety once judging data exists. +- Server-rendered initial data, matching loading skeletons, responsive layouts, and accessible controls. + +### Out of scope + +- Choosing award winners or publishing results to hackers. +- Assigning projects to presentation time slots. +- Scheduling routes through judging rooms. +- Judge calibration or score normalization. +- Shared deliberation boards or live collaborative sorting. +- Viewing another judge's private responses. +- Deleting an evaluation. + +## Vocabulary + +- `Evaluation`: one judge's answers for one project in one challenge. +- `Quantitative item`: a rubric question answered with an integer from 1 through 5. +- `Short-response item`: a rubric question answered with text and an explicit visibility rule. +- `Scoped rating`: the mean evaluation score for one project and one challenge. +- `Overall rating`: the mean evaluation score for one project across every challenge and judge. +- `Submission`: the judge-facing record of an evaluation. +- `Deliberation section`: a private named and ordered list of projects created by one judge. +- `Project command center`: the officer workspace for project inventory, rubric, judging state, result visibility, and rooms. + +## Acceptance criteria + +- Officers can build and reorder a hackathon rubric without changing code. +- Officers cannot open judging until the rubric contains at least one quantitative item. +- Each quantitative answer accepts only integers from 1 through 5. +- The evaluation score is the arithmetic mean of all quantitative answers in that evaluation. +- The scoped rating is the arithmetic mean of evaluation scores for the exact project and challenge. +- The overall rating is the arithmetic mean of all evaluation scores for the project. Every evaluation has equal weight. +- A judge can submit one evaluation for the same project in each eligible challenge and can edit each evaluation while judging is open. +- A judge sees `(?)` for a scoped aggregate until they submit in that scope, unless they are an authenticated member judge and an officer enabled `Display all results`. +- Guests never receive results or projects outside their configured challenge. +- The evaluation form states the audience for each short response and applies that rule on every read. +- `Submissions` shows only the current judge's evaluations and supports editing while Open. +- `Deliberation` accepts only projects the current judge has evaluated. +- Pointer drag, keyboard movement, and explicit move controls provide equivalent ordering behavior. +- Draft blocks evaluation submission. Closed makes evaluations and deliberation read-only. Reopening restores edits. +- The first evaluation locks rubric changes and full inventory replacement. Add-only import remains available for unseen normalized Devpost URLs. +- Projects referenced by evaluations or deliberation entries cannot be hard deleted. Soft-deleted projects remain visible in personal records as unavailable. +- The project command center combines project import, rubric, judging controls, and room operations without removing existing bookmarked admin entry points. +- Server-side authorization enforces every guest, member, and officer distinction. Hidden controls are not the security boundary. +- Initial route data renders on the server. Loading states use skeletons that match the loaded layout on desktop and mobile. + +## Open questions + +None. Product decisions approved on 2026-09-05. diff --git a/.forge/features/judging-scores-and-deliberation/srd.md b/.forge/features/judging-scores-and-deliberation/srd.md new file mode 100644 index 000000000..abd66c954 --- /dev/null +++ b/.forge/features/judging-scores-and-deliberation/srd.md @@ -0,0 +1,224 @@ +# Judging scores and deliberation SRD + +Status: Approved + +## Technical purpose + +Add configurable rubric, evaluation, aggregate-score, personal history, and private deliberation capabilities to the current room-scoped judging system. Consolidate the officer's project and room work into one command center while keeping old admin links compatible. + +## Relevant principles + +- Follow `docs/agentic-development/forge-engineering-principles.md`, especially thin apps, shared validators, server-enforced authorization, configurable yearly behavior, auditable mutations, and testable business rules. +- Follow `docs/REPO-CONVENTIONS.md` for Blade server components, tRPC boundaries, database ownership, and package imports. +- Follow `docs/DATABASE-USAGE.md` for current project, challenge, room, judge, and import semantics. +- Follow `apps/blade/DESIGN_SYSTEM.md` for dark-first tokens, border-led hierarchy, full-width workspaces, responsive overlays, accessible reordering, and skeleton parity. + +## Access policy + +### Unauthenticated users + +Ordinary unauthenticated users cannot read judging data. A valid guest judging cookie remains the only unauthenticated entry. The server validates its hashed credential, expiry, revoked link state, room, hackathon, and challenge on every protected judging operation. + +### Guest judges + +Guest principals can read projects assigned to the room challenge, read the active rubric, save an evaluation while judging is Open, review their own submissions, manage their own deliberation sections while Open, and read a scoped aggregate after submitting in that scope. + +Guest inputs never choose `hackathonId`, `challengeId`, or `judgeId` as trusted authority. The API derives those values from the validated guest session. Guests never receive overall scores, out-of-scope projects, other judges' answers, or the member result-visibility override. + +### Authenticated member judges + +A member principal requires role-derived `IS_JUDGE` or `IS_OFFICER`. The API upserts or resolves the member's hackathon-scoped `Judge` row before personal operations. + +Member judges can select an imported challenge, submit and edit within it, see their own submissions, and manage their own deliberation sections. They see an overall rating column. Scoped aggregate visibility follows the own-submission gate unless the hackathon's `displayAllResultsToMembers` setting is true. + +### Officers + +Officer procedures use `permProcedure` and call an explicit `IS_OFFICER` guard before any database work. Officers configure the rubric and judging state, control member result visibility, import projects, manage rooms and QR links, and inspect evaluation revision history for audit or dispute resolution. + +Every officer mutation has audit coverage. Evaluation text does not enter generic audit metadata. + +## Architecture and data flow + +- `@forge/db` owns the new tables, enums, relations, indexes, and generated migration. +- `@forge/validators` owns rubric, evaluation, deliberation, lifecycle, and reorder schemas. +- `@forge/api` owns principal resolution, lifecycle rules, score math, visibility filtering, import guards, transactions, and audit events. +- Blade pages remain server components. They authenticate, check route access, load initial tRPC data, and render feature components. +- Blade client components own dialogs, tabs, optimistic movement, mutations, toasts, URL state, and `router.refresh()` after server-prop changes. +- Do not add REST routes. The existing guest-cookie exchange remains the auth boundary established by `judging-magic-access`. + +### Judge page flow + +1. The server resolves the guest or member principal and loads hackathon context, rubric, lifecycle state, current challenge, project rows, the judge's submissions, and deliberation sections needed for the selected tab. +2. `Projects` sends challenge selection through URL state for members. Guests receive a locked challenge control or label. +3. Opening an evaluation uses the loaded rubric and project summary. Submitting calls a principal-aware mutation. +4. The API derives scope, validates lifecycle and answers, upserts the current evaluation transactionally, stores a revision snapshot, and returns the fresh score visibility state. +5. The client closes the overlay, shows a toast, and refreshes server-rendered data. + +### Score math + +For evaluation `e` with `n` quantitative answers: + +`evaluationMean(e) = sum(answer.value) / n` + +For project `p` and challenge `c` with `m` evaluations: + +`scopedMean(p, c) = sum(evaluationMean(e)) / m` + +For project `p` with `k` evaluations across all challenges: + +`overallMean(p) = sum(evaluationMean(e)) / k` + +Each completed evaluation has equal weight. Do not average criterion columns globally, weight challenges equally, normalize judges, or round stored data. Compute with database numeric expressions or application decimals and round only the displayed value to two decimal places. Return the evaluation count with an aggregate. No evaluations returns `null`, rendered as `(?)`. + +### Short-response visibility + +Each short-response rubric item stores separate policies for member and guest judges with enum values `public`, `public_optional`, and `private`. + +- `public`: the judging team may read the response. +- `public_optional`: the author chooses at submission time. Default the choice to private. +- `private`: only the author and officers handling a judging dispute may read it. + +The default rubric policy is `public` for member judges and `public_optional` for guests. This matches the current product rule that authenticated judge feedback is public while retaining an explicit schema for future hackathons. The command center presents both policies. For KH IX, the member policy control is fixed to `public`; changing that policy requires an explicit future product decision. + +Project teams do not receive short responses in this slice. + +## tRPC and API behavior + +Extend the current project and judging routers rather than introducing a parallel auth stack. Procedure names may adapt to existing router organization, but the capabilities are: + +### Judge reads + +- `projects.listJudge`: add `ownEvaluation`, `scopedRating`, `overallRating`, and visibility-safe counts. Preserve pagination, search, challenge filtering, and guest scope. +- `judging.getWorkspace`: return lifecycle state, display-safe rubric items, principal kind, selected challenge, room context, and result visibility flags. +- `judging.listMySubmissions`: return only the resolved judge's evaluations with answers, computed score, project availability, challenge, and timestamps. +- `judging.listMyDeliberation`: return only the resolved judge's ordered sections and entries. + +### Judge mutations + +- `judging.saveEvaluation`: create or edit one evaluation for the resolved judge, project, and derived or selected challenge. Reject Draft and Closed states, deleted projects, ineligible challenges, incomplete quantitative answers, invalid visibility choices, and stale guest access. +- `judging.createDeliberationSection`, `renameDeliberationSection`, `deleteDeliberationSection`, and `reorderDeliberationSections`. +- `judging.addDeliberationProject`, `removeDeliberationProject`, and `reorderDeliberationProjects`. + +Every deliberation mutation checks ownership and lifecycle state. Adding a project also checks that the judge has an evaluation for that project in any eligible challenge. + +### Officer reads and mutations + +- `judging.getCommandCenter`: load hackathon config, rubric, project/import summary, rooms, QR state, and live roster through bounded queries. +- `judging.saveRubric`: replace ordered rubric items only while Draft and before the first evaluation. Stable item IDs carry identity through edits and reordering. +- `judging.setState`: permit Draft to Open only with at least one rating item. Permit Open to Closed and Closed to Open. Do not return to Draft after an evaluation exists. +- `judging.setDisplayAllResults`: update the member-only result setting. +- Existing room, link, revoke, import, restore, and project procedures remain callable and appear in the command center. + +Use stable error codes and plain messages for `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, and `BAD_REQUEST`. Never reveal whether an out-of-scope guest project or challenge exists. + +## Validation + +Add shared Zod schemas with the following rules: + +- rubric label: trimmed, 1 to 120 characters; +- rubric description: trimmed, optional, at most 500 characters; +- rubric kind: `rating` or `short_response`; +- rubric array: stable IDs unique, display orders normalized; +- rating answer: integer from 1 through 5; +- short answer: trimmed, at most 2,000 characters, optional unless the item marks it required; +- response visibility: accepted only for `public_optional`; the server derives it for `public` and `private`; +- project and challenge IDs: positive integers; +- section name: trimmed, 1 to 80 characters; +- reorder payload: unique positive IDs, complete membership checked against server state; +- selected tab: `projects`, `submissions`, or `deliberation`; +- judging state: `draft`, `open`, or `closed`. + +The server validates that answer item IDs exactly match the current rubric's required items. Reject missing, duplicate, extra, or wrong-kind answers. Opening judging requires at least one rating item. + +## Data, migration, and compatibility + +### Configuration + +Extend `HackathonJudgingConfiguration` with `state`, enum `draft | open | closed`, default `draft`; `displayAllResultsToMembers`, boolean, default `false`; and nullable `openedAt` and `closedAt` timestamps. + +Keep the existing inventory-lock fields. A missing configuration row behaves as Draft with result display disabled. + +### Rubric + +Add `JudgingRubricItem` with `id`, `hackathonId`, `kind`, `label`, `description`, `displayOrder`, `required`, `memberVisibilityPolicy`, `guestVisibilityPolicy`, and timestamps. Visibility policy columns are null for rating items and required for short-response items. Add unique `(hackathonId, displayOrder)` and same-hackathon relation checks where supported. + +### Evaluations + +Add: + +- `ProjectEvaluation`: hackathon, project, challenge, judge, revision, created and updated timestamps, unique `(judgeId, projectId, challengeId)`; +- `ProjectEvaluationRating`: evaluation, rubric item, integer value, unique `(evaluationId, rubricItemId)`; +- `ProjectEvaluationResponse`: evaluation, rubric item, text response, resolved `isPublic`, unique `(evaluationId, rubricItemId)`; +- `ProjectEvaluationRevision`: evaluation, revision number, actor kind, rating and response snapshots as protected JSON, created timestamp, unique `(evaluationId, revision)`. + +Use composite foreign keys or transactional same-hackathon checks so project, challenge, rubric, judge, and evaluation records cannot cross hackathons. Ratings need a database check constraint from 1 through 5. + +An edit writes a complete snapshot as the next revision in the same transaction. Generic audit logs record the mutation and identifiers, never response text. + +### Deliberation + +Add: + +- `JudgeDeliberationSection`: hackathon, judge, name, display order, timestamps, unique `(judgeId, displayOrder)`; +- `JudgeDeliberationEntry`: section, project, display order, timestamps, unique `(sectionId, projectId)` and `(sectionId, displayOrder)`. + +Project foreign keys use restrict for hard deletion. Section deletion cascades to its entries. Judge deletion follows the existing identity retention policy. + +### Import and deletion rules + +- The first evaluation sets the existing inventory lock if it is not already set. +- After any evaluation exists, an ordinary Devpost import remains add-only and identifies new projects by normalized Devpost URL. +- Full replacement returns `CONFLICT` after any evaluation exists, even if all QR links are revoked. +- Hard project deletion returns `CONFLICT` when evaluations or deliberation entries reference the project. +- Soft deletion remains available to officers. Personal submissions and deliberation return the row with `available: false` and disable navigation or editing. +- Never cascade-delete evaluations, responses, revisions, or deliberation entries from a project operation. + +### Backup policy + +Treat evaluations, text responses, revisions, and deliberation lists as sensitive judging data. Add the new tables to the same development-backup exclusion policy as judge identities and guest sessions unless the repository's sanitizer supports deterministic replacement. + +### Compatibility and rollout + +- Preserve `/admin/projects` with a server redirect to the project command center's projects tab. +- Preserve current guest QR URLs and cookies. +- Existing hackathons start in Draft and have no rubric until an officer configures one. +- The migration is additive. Rollback drops only new tables and columns before production data exists. After evaluations exist, rollback requires an explicit data export and maintenance window. + +## Discord integration + +No new Discord calls or role writes. Member access continues to use the existing role permission model. The server reads `IS_JUDGE` and `IS_OFFICER`; Discord remains the source of role membership. + +## Configurability review + +Would this require a developer change next year? + +- No for criterion count, labels, descriptions, order, required state, short-response policies, judging state, project inventory, challenges, rooms, or the member result reveal. +- A code change is appropriate only for a new answer type, a different score formula, or a new access model. + +## React and frontend constraints + +- Keep route pages thin and server-first. Do not put `use client` on a page. +- Pass server-read data into client feature components. Do not immediately re-fetch it with client tRPC. +- Use `Tabs` with URL-backed state. Keep guest restrictions visible and disabled rather than hiding the current challenge. +- Use the existing responsive project table and mobile cards. Add `Rating` and member-only `Overall rating` columns without creating document-level horizontal overflow. +- Use a dialog on desktop and a viewport-safe drawer or dialog on mobile for evaluation and bounded rubric editing. +- Use radio groups or segmented 1 through 5 controls with visible numeric labels, 44px targets, keyboard input, and focus rings. +- Show feedback visibility beside each text field and again near Submit. Do not rely on color alone. +- Deliberation uses pointer drag and drop, keyboard movement, and explicit move buttons. Stable database IDs carry identity. +- Mutation success closes overlays, shows a toast, and refreshes server props. Failure leaves entered data intact and places the error beside the action. +- Add route-level loading files or equivalent skeletons for the judge workspace and command center. Skeleton geometry must match the loaded desktop and mobile layouts. +- Use only design tokens and existing `@forge/ui` primitives. Gold is limited to live or award-related status, not ordinary controls. +- Verify at 1440 by 1000, 390 by 844, and 320px width. Honor reduced motion. + +## Testing and verification strategy + +- Validator unit tests cover rubric and evaluation payloads. +- API unit or integration tests cover principal scope, lifecycle, visibility, score math, editing, revision history, and mutation authorization. +- Database migration checks cover a fresh database and an upgrade from the current schema. +- Blade component or Playwright tests cover the three tabs, dialog copy, score gating, command center controls, mobile behavior, and accessible reordering. +- Add deterministic visual baselines only if the fixture can isolate the judging hackathon and project rows. Otherwise capture review screenshots without committing them. +- Run `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm analyze:react:changed`, relevant package tests, migration checks, Blade build, and targeted Playwright tests. + +## Open questions + +None. The human approved this SRD and the 20-case test plan on 2026-09-05. diff --git a/.forge/features/judging-scores-and-deliberation/status.md b/.forge/features/judging-scores-and-deliberation/status.md new file mode 100644 index 000000000..82c7aa620 --- /dev/null +++ b/.forge/features/judging-scores-and-deliberation/status.md @@ -0,0 +1,58 @@ +# Judging scores and deliberation status + +Current phase: Implementation + +## Decision log + +- 2026-09-05: This bundle extends `project-judging` and `judging-magic-access`. It does not replace their project, room, guest-session, or judge identity models. +- 2026-09-05: The human approved the feature spec, SRD, and exactly 20 observable test cases. +- 2026-09-05: Officers configure any number of 1 through 5 rating items and short-response items per hackathon. Rubric changes require no yearly code edit. +- 2026-09-05: One evaluation belongs to one judge, project, and challenge. A judge may evaluate the same project in several challenge scopes and may edit each evaluation while judging is Open. +- 2026-09-05: An evaluation score averages its quantitative answers. Scoped and overall ratings average evaluation scores with no judge calibration or per-challenge scaling. +- 2026-09-05: A judge sees `(?)` until they evaluate the project in that challenge. Officers may reveal scoped results early to authenticated member judges. Guest result access never widens. +- 2026-09-05: Overall ratings appear only to authenticated member judges and officers. +- 2026-09-05: Short-response items carry public, public-optional, or private policies. KH IX member feedback is public. Guest optional-public feedback defaults private and lets the guest opt in. +- 2026-09-05: Judging state is Draft, Open, or Closed. Closed is read-only and may reopen. At least one rating item is required to open. +- 2026-09-05: The judge workspace tabs are `Projects`, `Submissions`, and `Deliberation`. Deliberation is private, available to guests and members, accepts judged projects, and supports accessible ordering. +- 2026-09-05: The first evaluation locks rubric changes and destructive inventory replacement. Ordinary imports remain add-only by normalized Devpost URL. +- 2026-09-05: The officer project command center combines project import, rubric and lifecycle configuration, result visibility, rooms, QR controls, and live rosters. Existing admin entry points remain compatible. +- 2026-09-05: Exit condition is a depth-5 Forge review with no blockers, an issue and PR that follow repository standards, many externally hosted review screenshots, and CodeRabbit approval after every actionable thread is fixed, replied to, and resolved. +- 2026-09-05: Screenshots must never be committed to the feature branch. + +## Open questions + +None. + +## Task list + +- [x] Complete reverse-prompting for `spec.md`. +- [x] Complete reverse-prompting for `srd.md`. +- [x] Complete reverse-prompting for `test-cases.md`. +- [x] Record human approval for the SRD and 20 test cases. +- [ ] Add validator and score-math tests. +- [ ] Add schema and generated migration. +- [ ] Add API procedures, authorization, transactions, and audit coverage. +- [ ] Add project command center and compatibility routing. +- [ ] Add Projects, Submissions, and Deliberation judge tabs. +- [ ] Add matching loading, error, empty, desktop, and mobile states. +- [ ] Run automated checks and targeted visual verification. +- [ ] Run Forge review at depth 5 and clear all blockers. +- [ ] Sync and rebase onto current GitHub `main` once host Git access is available. +- [ ] Create and assign the GitHub issue with required labels. +- [ ] Push the branch and open a fully documented PR. +- [ ] Upload many screenshots to GitHub discussion only. +- [ ] Address, reply to, resolve, and re-request CodeRabbit review until approved. + +## Validation and commands + +- `node --experimental-strip-types scripts/create-forge-feature.ts judging-scores-and-deliberation "Judging Scores and Deliberation"`: passed. +- Repository history review: the retired 2025 rubric used five fixed 1 through 10 fields and separate public and private feedback. This bundle replaces that fixed shape with hackathon data. +- Published KH8 Devpost review: confirmed Originality, Technical Understanding, Functionality, Design, and Wow Factor as useful seed content, not code constants. +- GitHub browser review: PR #529 establishes the expected issue linking, detailed flow narrative, labels, test evidence, and externally hosted screenshots. +- GitHub CLI login attempt: blocked before device-code creation because this task shell cannot reach `github.com`. The existing CLI token is invalid. Local implementation continues while host authentication is resolved. + +## Links + +- PRs: +- Issues: +- Reference PR: https://github.com/KnightHacks/forge/pull/529 diff --git a/.forge/features/judging-scores-and-deliberation/test-cases.md b/.forge/features/judging-scores-and-deliberation/test-cases.md new file mode 100644 index 000000000..e6e3fcd8b --- /dev/null +++ b/.forge/features/judging-scores-and-deliberation/test-cases.md @@ -0,0 +1,351 @@ +# Judging scores and deliberation test cases + +Status: Approved + +## Scope + +These 20 cases cover rubric configuration, judging lifecycle, member and guest evaluation access, score calculation and disclosure, editable submissions, response privacy, personal deliberation, import safety, and the combined officer command center. Scheduling, winner selection, hacker-facing publication, and collaborative deliberation are excluded. + +## Test placement plan + +- `@forge/validators`: unit tests for rubric, answer, visibility, section, and reorder payloads. +- `@forge/api`: unit and disposable-database integration tests for principals, lifecycle, score math, visibility, persistence, revision history, import locks, and authorization. +- `@forge/db`: schema and migration tests for constraints, fresh migration, and prod-like upgrades. +- `@forge/blade`: component tests and targeted Playwright coverage for the three tabs, dialogs, command center, keyboard reordering, responsive layouts, and skeletons. + +## Test cases + +### TC-001: Officer creates a configurable rubric + +Setup: + +- A hackathon is in Draft with no evaluations. +- An officer opens the project command center. + +Action: + +- Add three rating items and two short-response items, edit labels and descriptions, set required flags and response policies, reorder them, and save. + +Expected observations: + +- The saved order and configuration survive refresh. +- Rating items always use the 1 through 5 scale. +- Stable item IDs survive editing and reordering. +- A non-officer cannot call the same mutation. + +### TC-002: Rubric validation rejects malformed configuration + +Setup: + +- A hackathon is in Draft with no evaluations. + +Action: + +- Submit empty or overlong labels, duplicate IDs, a visibility policy on a rating item, a missing policy on a short response, and malformed order data. + +Expected observations: + +- The validator or API returns `BAD_REQUEST` with field-level errors. +- No partial rubric update reaches the database. + +### TC-003: Opening judging requires a quantitative item + +Setup: + +- A Draft rubric contains only short-response items. + +Action: + +- An officer changes the state to Open. + +Expected observations: + +- The API returns `CONFLICT` or `BAD_REQUEST` with a plain explanation. +- Adding one rating item allows the transition and records `openedAt`. + +### TC-004: Lifecycle controls judge writes + +Setup: + +- A valid rubric and eligible project exist. + +Action: + +- Attempt an evaluation in Draft, submit and edit in Open, close judging, attempt another edit and deliberation reorder, then reopen. + +Expected observations: + +- Draft rejects evaluation writes. +- Open permits evaluation and deliberation writes. +- Closed shows read-only data and rejects writes server-side. +- Reopening restores allowed edits without losing data. + +### TC-005: Member saves one evaluation per project and challenge + +Setup: + +- A member judge can access two challenges assigned to one project. +- Judging is Open. + +Action: + +- Submit the project once in each challenge, then attempt a second create in the first challenge. + +Expected observations: + +- Two evaluations exist, one per challenge. +- The second save in the first challenge edits the existing evaluation instead of creating a duplicate. +- The unique database constraint prevents races from creating duplicates. + +### TC-006: Guest scope cannot be widened + +Setup: + +- A valid guest session belongs to a room assigned to Challenge A. +- Projects exist in Challenge A, Challenge B, and General. + +Action: + +- Read projects and scores, then tamper with evaluation and list inputs to send Challenge B or another hackathon. + +Expected observations: + +- Only Challenge A projects and aggregates are returned. +- The API derives Challenge A from the session and rejects out-of-scope projects without confirming their existence. +- No overall rating field is returned. + +### TC-007: Evaluation answers match the active rubric + +Setup: + +- The active rubric has three required rating items, one required response, and one optional response. + +Action: + +- Submit values below 1, above 5, non-integers, missing required items, duplicate item IDs, extra IDs, wrong-kind answers, and an overlong response. + +Expected observations: + +- Every malformed payload is rejected. +- A complete payload with integer ratings from 1 through 5 succeeds atomically. + +### TC-008: Scoped score uses evaluation means + +Setup: + +- In one project and challenge, Judge A answers `5, 5`, Judge B answers `1, 3`, and Judge C answers `4, 2` across two rating items. + +Action: + +- Read the scoped aggregate. + +Expected observations: + +- Evaluation means are `5`, `2`, and `3`. +- The scoped result is `3.33` when displayed and has count `3`. +- The system does not sum raw criteria or store a rounded aggregate. + +### TC-009: Overall score weights every evaluation equally + +Setup: + +- Project P has two evaluations in Challenge A with means `5` and `3`, plus one evaluation in Challenge B with mean `1`. + +Action: + +- An authenticated member judge reads the overall rating. + +Expected observations: + +- The overall result is `3.00` with count `3`. +- It is not `2.50`, which would incorrectly give each challenge equal weight. +- A guest response omits this field. + +### TC-010: Result disclosure waits for the judge's submission + +Setup: + +- Other judges have rated a project in the selected challenge. +- `Display all results` is off. + +Action: + +- A member judge and a guest judge view the project before and after saving their own evaluations. + +Expected observations: + +- Both see `(?)` before saving and the scoped aggregate after saving. +- The API omits or nulls the hidden value rather than sending it for client-only concealment. + +### TC-011: Officer reveal applies only to authenticated member judges + +Setup: + +- Aggregates exist and neither viewer has rated the project. + +Action: + +- An officer enables `Display all results`, then a member judge and guest judge refresh. + +Expected observations: + +- The member sees the scoped aggregate immediately. +- The guest still sees `(?)` and receives no widened result data. +- Disabling the setting restores the member's own-submission gate. + +### TC-012: Empty scores display as unknown + +Setup: + +- A project has no evaluations in the selected challenge and no evaluations overall. + +Action: + +- Open the project table as an eligible member and as a guest. + +Expected observations: + +- Available score cells render `(?)`, not `0`, `NaN`, or an empty string. +- Counts do not claim that an evaluation exists. + +### TC-013: Editing preserves one current evaluation and revision history + +Setup: + +- A judge has a saved evaluation at revision 1 while judging is Open. + +Action: + +- Change ratings and feedback from `Submissions` and save. + +Expected observations: + +- The current evaluation becomes revision 2 with an updated timestamp. +- The prior and current complete snapshots remain available to authorized officer inspection. +- Generic audit metadata contains IDs and revision numbers but no feedback text. + +### TC-014: Submissions are private to the current judge + +Setup: + +- Two judges have submissions in the same hackathon. + +Action: + +- Judge A opens `Submissions` and tampers with identifiers to request or edit Judge B's evaluation. + +Expected observations: + +- Judge A sees only their own records. +- Reads and edits for Judge B return `NOT_FOUND` or `FORBIDDEN` without leaking answer content. +- Officers do not gain an accidental edit-as-judge path. + +### TC-015: Short-response visibility follows judge type and item policy + +Setup: + +- The rubric has public, public-optional, and private short-response items for guests. Member items use the KH IX public policy. + +Action: + +- A member submits feedback. A guest submits public-optional feedback once private and once public, plus public and private item responses. + +Expected observations: + +- The form states the audience before submission. +- Member responses resolve public. +- Guest public and private items ignore tampered visibility inputs. +- Guest public-optional responses use the explicit choice and default to private. +- Unauthorized judges never receive private text. + +### TC-016: Judge manages private deliberation sections + +Setup: + +- A judge has evaluated three projects. + +Action: + +- Create and rename two sections, reorder the sections, add the same project to both, and remove it from one. + +Expected observations: + +- Section names and order survive refresh. +- A project appears at most once per section but may appear in several sections. +- Removing an entry does not change its evaluation. +- Another judge cannot read or mutate these sections. + +### TC-017: Deliberation ordering is accessible and durable + +Setup: + +- A section contains three evaluated projects. + +Action: + +- Reorder with pointer drag, then use keyboard movement and explicit up or down controls at desktop and 320px widths. + +Expected observations: + +- All methods produce the same stored order. +- Focus remains usable, controls have accessible names and 44px targets, and no document-level horizontal overflow appears. +- A partial, duplicate, foreign, or stale reorder payload is rejected without changing the prior order. + +### TC-018: Deliberation accepts only evaluated projects + +Setup: + +- The judge has evaluated Project A but not Project B. Project C was evaluated and later soft deleted. + +Action: + +- Add all three projects to a section and attempt a duplicate add. + +Expected observations: + +- Project A succeeds, Project B is rejected, and a duplicate A is rejected. +- Existing Project C entries remain visible as unavailable and cannot open an evaluation editor. + +### TC-019: First evaluation locks rubric and destructive inventory changes + +Setup: + +- An Open hackathon has an unlocked inventory and valid rubric. + +Action: + +- Save the first evaluation, then try to edit the rubric, fully replace the Devpost inventory, and hard delete its project. Run an ordinary import containing one unseen normalized Devpost URL. + +Expected observations: + +- The evaluation and inventory lock commit in one transaction. +- Rubric edits and full replacement return `CONFLICT`. +- Hard delete is blocked by the evaluation reference. +- Add-only import inserts only the unseen project and leaves existing rows untouched. + +### TC-020: Command center and judge workspace render complete responsive flows + +Setup: + +- A hackathon has projects, rubric items, several rooms, QR states, live judges, submissions, and deliberation data. + +Action: + +- Load the project command center and all three judge tabs as officer, member judge, challenge guest, and General guest at desktop and mobile sizes. Exercise refresh, back, forward, loading, empty, error, and success states. + +Expected observations: + +- The command center combines project import, rubric, lifecycle, member result visibility, rooms, QR controls, and live roster. +- `/admin/projects` reaches the projects section without losing bookmarked access. +- Judge tabs are named `Projects`, `Submissions`, and `Deliberation`; the last includes a short purpose explanation. +- Guests retain the minimal shell and fixed challenge. Members retain Blade navigation and optional room selection. +- Server HTML contains initial data, and skeletons match loaded geometry without flashing an empty client shell. +- Screenshots show intentional hierarchy, readable score columns, viewport-safe evaluation forms, and no sensitive team or private-feedback data. + +## Negative and regression coverage + +Negative and regression behavior is integrated into TC-002, TC-003, TC-004, TC-006, TC-007, TC-009 through TC-012, TC-014, TC-015, and TC-017 through TC-020 so the approved count remains exactly 20. + +## Open questions + +None. The human approved these 20 cases on 2026-09-05. diff --git a/packages/db/src/schemas/knight-hacks.ts b/packages/db/src/schemas/knight-hacks.ts index bc14c26a8..58782da1a 100644 --- a/packages/db/src/schemas/knight-hacks.ts +++ b/packages/db/src/schemas/knight-hacks.ts @@ -135,6 +135,19 @@ export const alumniBulletinStateEnum = pgEnum("alumni_bulletin_state", [ "archived", ]); export const judgeKindEnum = pgEnum("judge_kind", ["member", "guest"]); +export const judgingStateEnum = pgEnum("judging_state", [ + "draft", + "open", + "closed", +]); +export const judgingRubricItemKindEnum = pgEnum("judging_rubric_item_kind", [ + "rating", + "short_response", +]); +export const judgingResponseVisibilityEnum = pgEnum( + "judging_response_visibility", + ["public", "public_optional", "private"], +); export const Hackathon = createTable( "hackathon", @@ -2241,6 +2254,10 @@ export const HackathonJudgingConfiguration = createTable( projectInventoryLockedByUserId: t .uuid() .references(() => User.id, { onDelete: "set null" }), + state: judgingStateEnum().notNull().default("draft"), + displayAllResultsToMembers: t.boolean().notNull().default(false), + openedAt: t.timestamp({ withTimezone: true }), + closedAt: t.timestamp({ withTimezone: true }), createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), updatedAt: t .timestamp({ withTimezone: true }) @@ -2436,6 +2453,249 @@ export const JudgingRoomPresence = createTable( }), ); +export const JudgingRubricItem = createTable( + "judging_rubric_item", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + hackathonId: t + .uuid() + .notNull() + .references(() => Hackathon.id, { onDelete: "cascade" }), + kind: judgingRubricItemKindEnum().notNull(), + label: t.varchar({ length: 120 }).notNull(), + description: t.varchar({ length: 500 }).notNull().default(""), + displayOrder: t.integer().notNull(), + required: t.boolean().notNull().default(true), + memberVisibilityPolicy: judgingResponseVisibilityEnum(), + guestVisibilityPolicy: judgingResponseVisibilityEnum(), + createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: t + .timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + (table) => ({ + displayOrderCheck: check( + "knight_hacks_judging_rubric_item_display_order_check", + sql`${table.displayOrder} >= 0`, + ), + visibilityCheck: check( + "knight_hacks_judging_rubric_item_visibility_check", + sql`(${table.kind} = 'rating' AND ${table.memberVisibilityPolicy} IS NULL AND ${table.guestVisibilityPolicy} IS NULL) OR (${table.kind} = 'short_response' AND ${table.memberVisibilityPolicy} IS NOT NULL AND ${table.guestVisibilityPolicy} IS NOT NULL)`, + ), + orderUnique: unique( + "knight_hacks_judging_rubric_item_hackathon_order_unique", + ).on(table.hackathonId, table.displayOrder), + hackathonScopeUnique: unique( + "knight_hacks_judging_rubric_item_id_hackathon_unique", + ).on(table.id, table.hackathonId), + }), +); + +export const ProjectEvaluation = createTable( + "project_evaluation", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + hackathonId: t.uuid().notNull(), + projectId: t.uuid().notNull(), + challengeId: t.uuid().notNull(), + judgeId: t.uuid().notNull(), + revision: t.integer().notNull().default(1), + createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: t + .timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + (table) => ({ + projectScopeFk: foreignKey({ + columns: [table.projectId, table.hackathonId], + foreignColumns: [Project.id, Project.hackathonId], + name: "knight_hacks_project_evaluation_project_scope_fk", + }).onDelete("restrict"), + challengeScopeFk: foreignKey({ + columns: [table.challengeId, table.hackathonId], + foreignColumns: [ProjectChallenge.id, ProjectChallenge.hackathonId], + name: "knight_hacks_project_evaluation_challenge_scope_fk", + }).onDelete("restrict"), + judgeScopeFk: foreignKey({ + columns: [table.judgeId, table.hackathonId], + foreignColumns: [Judge.id, Judge.hackathonId], + name: "knight_hacks_project_evaluation_judge_scope_fk", + }).onDelete("restrict"), + judgeProjectChallengeUnique: unique( + "knight_hacks_project_evaluation_judge_project_challenge_unique", + ).on(table.judgeId, table.projectId, table.challengeId), + hackathonScopeUnique: unique( + "knight_hacks_project_evaluation_id_hackathon_unique", + ).on(table.id, table.hackathonId), + projectChallengeIdx: index( + "knight_hacks_project_evaluation_project_challenge_idx", + ).on(table.projectId, table.challengeId), + judgeIdx: index("knight_hacks_project_evaluation_judge_idx").on( + table.judgeId, + table.updatedAt, + ), + }), +); + +export const ProjectEvaluationRating = createTable( + "project_evaluation_rating", + (t) => ({ + evaluationId: t.uuid().notNull(), + rubricItemId: t.uuid().notNull(), + hackathonId: t.uuid().notNull(), + value: t.integer().notNull(), + }), + (table) => ({ + pk: primaryKey({ columns: [table.evaluationId, table.rubricItemId] }), + evaluationScopeFk: foreignKey({ + columns: [table.evaluationId, table.hackathonId], + foreignColumns: [ProjectEvaluation.id, ProjectEvaluation.hackathonId], + name: "knight_hacks_project_evaluation_rating_evaluation_scope_fk", + }).onDelete("cascade"), + rubricScopeFk: foreignKey({ + columns: [table.rubricItemId, table.hackathonId], + foreignColumns: [JudgingRubricItem.id, JudgingRubricItem.hackathonId], + name: "knight_hacks_project_evaluation_rating_rubric_scope_fk", + }).onDelete("restrict"), + valueCheck: check( + "knight_hacks_project_evaluation_rating_value_check", + sql`${table.value} BETWEEN 1 AND 5`, + ), + }), +); + +export const ProjectEvaluationResponse = createTable( + "project_evaluation_response", + (t) => ({ + evaluationId: t.uuid().notNull(), + rubricItemId: t.uuid().notNull(), + hackathonId: t.uuid().notNull(), + value: t.text().notNull(), + isPublic: t.boolean().notNull(), + }), + (table) => ({ + pk: primaryKey({ columns: [table.evaluationId, table.rubricItemId] }), + evaluationScopeFk: foreignKey({ + columns: [table.evaluationId, table.hackathonId], + foreignColumns: [ProjectEvaluation.id, ProjectEvaluation.hackathonId], + name: "knight_hacks_project_evaluation_response_evaluation_scope_fk", + }).onDelete("cascade"), + rubricScopeFk: foreignKey({ + columns: [table.rubricItemId, table.hackathonId], + foreignColumns: [JudgingRubricItem.id, JudgingRubricItem.hackathonId], + name: "knight_hacks_project_evaluation_response_rubric_scope_fk", + }).onDelete("restrict"), + }), +); + +export const ProjectEvaluationRevision = createTable( + "project_evaluation_revision", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + evaluationId: t.uuid().notNull(), + hackathonId: t.uuid().notNull(), + revision: t.integer().notNull(), + actorKind: judgeKindEnum().notNull(), + ratingAnswers: t.jsonb().notNull().default([]), + responseAnswers: t.jsonb().notNull().default([]), + createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), + }), + (table) => ({ + evaluationScopeFk: foreignKey({ + columns: [table.evaluationId, table.hackathonId], + foreignColumns: [ProjectEvaluation.id, ProjectEvaluation.hackathonId], + name: "knight_hacks_project_evaluation_revision_evaluation_scope_fk", + }).onDelete("cascade"), + evaluationRevisionUnique: unique( + "knight_hacks_project_evaluation_revision_unique", + ).on(table.evaluationId, table.revision), + revisionCheck: check( + "knight_hacks_project_evaluation_revision_revision_check", + sql`${table.revision} >= 1`, + ), + }), +); + +export const JudgeDeliberationSection = createTable( + "judge_deliberation_section", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + hackathonId: t.uuid().notNull(), + judgeId: t.uuid().notNull(), + name: t.varchar({ length: 80 }).notNull(), + displayOrder: t.integer().notNull(), + createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: t + .timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + (table) => ({ + judgeScopeFk: foreignKey({ + columns: [table.judgeId, table.hackathonId], + foreignColumns: [Judge.id, Judge.hackathonId], + name: "knight_hacks_judge_deliberation_section_judge_scope_fk", + }).onDelete("cascade"), + judgeOrderUnique: unique( + "knight_hacks_judge_deliberation_section_judge_order_unique", + ).on(table.judgeId, table.displayOrder), + hackathonScopeUnique: unique( + "knight_hacks_judge_deliberation_section_id_hackathon_unique", + ).on(table.id, table.hackathonId), + displayOrderCheck: check( + "knight_hacks_judge_deliberation_section_order_check", + sql`${table.displayOrder} >= 0`, + ), + }), +); + +export const JudgeDeliberationEntry = createTable( + "judge_deliberation_entry", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + hackathonId: t.uuid().notNull(), + sectionId: t.uuid().notNull(), + projectId: t.uuid().notNull(), + displayOrder: t.integer().notNull(), + createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: t + .timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + (table) => ({ + sectionScopeFk: foreignKey({ + columns: [table.sectionId, table.hackathonId], + foreignColumns: [ + JudgeDeliberationSection.id, + JudgeDeliberationSection.hackathonId, + ], + name: "knight_hacks_judge_deliberation_entry_section_scope_fk", + }).onDelete("cascade"), + projectScopeFk: foreignKey({ + columns: [table.projectId, table.hackathonId], + foreignColumns: [Project.id, Project.hackathonId], + name: "knight_hacks_judge_deliberation_entry_project_scope_fk", + }).onDelete("restrict"), + sectionProjectUnique: unique( + "knight_hacks_judge_deliberation_entry_section_project_unique", + ).on(table.sectionId, table.projectId), + sectionOrderUnique: unique( + "knight_hacks_judge_deliberation_entry_section_order_unique", + ).on(table.sectionId, table.displayOrder), + displayOrderCheck: check( + "knight_hacks_judge_deliberation_entry_order_check", + sql`${table.displayOrder} >= 0`, + ), + }), +); + export const OtherCompanies = createTable("companies", (t) => ({ name: t.varchar({ length: 255 }).notNull().primaryKey(), })); diff --git a/packages/validators/src/judging.ts b/packages/validators/src/judging.ts index 978e99c79..7bca7bc6f 100644 --- a/packages/validators/src/judging.ts +++ b/packages/validators/src/judging.ts @@ -1,9 +1,121 @@ import { z } from "zod"; const roomNameSchema = z.string().trim().min(1).max(120); +const uuidSchema = z.string().uuid(); export const judgingHackathonIdSchema = z.object({ - hackathonId: z.string().uuid(), + hackathonId: uuidSchema, +}); + +export const judgingStateSchema = z.enum(["draft", "open", "closed"]); +export const judgingRubricItemKindSchema = z.enum(["rating", "short_response"]); +export const judgingResponseVisibilitySchema = z.enum([ + "public", + "public_optional", + "private", +]); + +export const judgingRubricItemSchema = z + .object({ + description: z.string().trim().max(500).optional().default(""), + guestVisibilityPolicy: judgingResponseVisibilitySchema.nullable(), + id: uuidSchema.optional(), + kind: judgingRubricItemKindSchema, + label: z.string().trim().min(1).max(120), + memberVisibilityPolicy: judgingResponseVisibilitySchema.nullable(), + required: z.boolean().default(true), + }) + .superRefine((item, ctx) => { + const policies = [item.guestVisibilityPolicy, item.memberVisibilityPolicy]; + if (item.kind === "rating" && policies.some((policy) => policy !== null)) { + ctx.addIssue({ + code: "custom", + message: "Rating items cannot have response visibility policies.", + }); + } + if ( + item.kind === "short_response" && + policies.some((policy) => policy === null) + ) { + ctx.addIssue({ + code: "custom", + message: + "Short responses require member and guest visibility policies.", + }); + } + }); + +export const judgingRubricSaveSchema = judgingHackathonIdSchema.extend({ + items: z.array(judgingRubricItemSchema).superRefine((items, ctx) => { + const ids = items.flatMap((item) => (item.id ? [item.id] : [])); + if (new Set(ids).size !== ids.length) { + ctx.addIssue({ + code: "custom", + message: "Rubric item IDs must be unique.", + }); + } + }), +}); + +export const judgingStateUpdateSchema = judgingHackathonIdSchema.extend({ + state: judgingStateSchema, +}); + +export const judgingResultsVisibilitySchema = judgingHackathonIdSchema.extend({ + displayAllResults: z.boolean(), +}); + +export const judgingRatingAnswerSchema = z.object({ + itemId: uuidSchema, + value: z.number().int().min(1).max(5), +}); + +export const judgingResponseAnswerSchema = z.object({ + isPublic: z.boolean().optional(), + itemId: uuidSchema, + value: z.string().trim().max(2000), +}); + +export const judgingEvaluationSaveSchema = z.object({ + challengeId: uuidSchema.optional(), + projectId: uuidSchema, + ratings: z.array(judgingRatingAnswerSchema), + responses: z.array(judgingResponseAnswerSchema), +}); + +export const judgingDeliberationSectionCreateSchema = z.object({ + hackathonId: uuidSchema.optional(), + name: z.string().trim().min(1).max(80), +}); + +export const judgingDeliberationSectionUpdateSchema = z.object({ + name: z.string().trim().min(1).max(80), + sectionId: uuidSchema, +}); + +export const judgingDeliberationSectionIdSchema = z.object({ + sectionId: uuidSchema, +}); + +export const judgingDeliberationEntrySchema = + judgingDeliberationSectionIdSchema.extend({ + projectId: uuidSchema, + }); + +export const judgingReorderSchema = z + .object({ ids: z.array(uuidSchema).min(1) }) + .superRefine(({ ids }, ctx) => { + if (new Set(ids).size !== ids.length) { + ctx.addIssue({ code: "custom", message: "Order IDs must be unique." }); + } + }); + +export const judgingSectionReorderSchema = judgingReorderSchema.extend({ + hackathonId: uuidSchema.optional(), +}); + +export const judgingEntryReorderSchema = judgingReorderSchema.extend({ + sectionId: uuidSchema, }); export const judgingRoomIdSchema = z.object({ diff --git a/packages/validators/src/tests/judging.test.ts b/packages/validators/src/tests/judging.test.ts index efc328b5f..f204c2204 100644 --- a/packages/validators/src/tests/judging.test.ts +++ b/packages/validators/src/tests/judging.test.ts @@ -2,12 +2,18 @@ import { describe, expect, it } from "vitest"; import { guestJudgeNameSchema, + judgingEvaluationSaveSchema, + judgingReorderSchema, judgingRoomCreateSchema, judgingRoomMoveSchema, + judgingRubricSaveSchema, } from "../judging"; const hackathonId = "00000000-0000-4000-8000-000000000001"; const challengeId = "00000000-0000-4000-8000-000000000002"; +const itemId = "00000000-0000-4000-8000-000000000003"; +const responseId = "00000000-0000-4000-8000-000000000004"; +const projectId = "00000000-0000-4000-8000-000000000005"; describe("judging inputs", () => { it("trims room and guest judge names", () => { @@ -61,4 +67,96 @@ describe("judging inputs", () => { }).success, ).toBe(false); }); + + it("accepts a data-driven rubric and trims its copy", () => { + const result = judgingRubricSaveSchema.parse({ + hackathonId, + items: [ + { + guestVisibilityPolicy: null, + kind: "rating", + label: " Technical understanding ", + memberVisibilityPolicy: null, + required: true, + }, + { + guestVisibilityPolicy: "public_optional", + kind: "short_response", + label: " Feedback ", + memberVisibilityPolicy: "public", + required: false, + }, + ], + }); + + expect(result.items.map((item) => item.label)).toEqual([ + "Technical understanding", + "Feedback", + ]); + }); + + it("rejects mismatched rubric visibility policies and duplicate IDs", () => { + expect( + judgingRubricSaveSchema.safeParse({ + hackathonId, + items: [ + { + guestVisibilityPolicy: "private", + id: itemId, + kind: "rating", + label: "Wow factor", + memberVisibilityPolicy: null, + }, + ], + }).success, + ).toBe(false); + expect( + judgingRubricSaveSchema.safeParse({ + hackathonId, + items: [ + { + guestVisibilityPolicy: null, + id: itemId, + kind: "rating", + label: "Wow factor", + memberVisibilityPolicy: null, + }, + { + guestVisibilityPolicy: null, + id: itemId, + kind: "rating", + label: "Originality", + memberVisibilityPolicy: null, + }, + ], + }).success, + ).toBe(false); + }); + + it("accepts only integer ratings from one through five", () => { + const base = { + projectId, + ratings: [{ itemId, value: 3 }], + responses: [{ itemId: responseId, value: "Useful feedback" }], + }; + + expect(judgingEvaluationSaveSchema.safeParse(base).success).toBe(true); + for (const value of [0, 1.5, 6]) { + expect( + judgingEvaluationSaveSchema.safeParse({ + ...base, + ratings: [{ itemId, value }], + }).success, + ).toBe(false); + } + }); + + it("rejects duplicate reorder IDs", () => { + expect( + judgingReorderSchema.safeParse({ ids: [itemId, itemId] }).success, + ).toBe(false); + expect( + judgingReorderSchema.safeParse({ ids: [itemId, responseId] }).success, + ).toBe(true); + }); }); From cb1fe66e7125a695adfc3ba113c20a4da0759769 Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sat, 5 Sep 2026 19:02:15 -0400 Subject: [PATCH 2/5] finish project judging workflows --- .../judging-scores-and-deliberation/spec.md | 21 +- .../judging-scores-and-deliberation/srd.md | 16 +- .../judging-scores-and-deliberation/status.md | 34 +- .../test-cases.md | 13 +- .../judging/evaluation-audit-panel.tsx | 233 + .../_components/judging/evaluation-dialog.tsx | 353 + .../judging/judge-deliberation.tsx | 627 + .../_components/judging/judge-submissions.tsx | 223 + .../judging/judging-configuration-panel.tsx | 378 + .../judging/judging-control-panel.tsx | 77 +- .../judging/project-command-center.tsx | 127 + .../judging/project-score-dialog.tsx | 165 + .../projects/admin-project-workspace.tsx | 139 +- .../projects/judge-project-workspace.tsx | 215 +- .../projects/judge-projects-loading.tsx | 13 + .../src/app/_components/projects/params.ts | 13 +- .../projects/project-directory.tsx | 132 +- .../projects/project-workspace-skeleton.tsx | 135 +- .../_components/shared/admin-navigation.ts | 19 +- apps/blade/src/app/admin/judging/loading.tsx | 31 +- apps/blade/src/app/admin/judging/page.tsx | 60 +- apps/blade/src/app/admin/projects/page.tsx | 69 +- apps/blade/src/app/judge/projects/loading.tsx | 4 +- apps/blade/src/app/judge/projects/page.tsx | 32 + .../tests/admin/authenticated-shell.test.tsx | 3 +- .../admin/hackathon-admin-navigation.test.tsx | 9 +- .../projects/admin-projects-redirect.test.ts | 31 + .../projects/evaluation-audit-panel.test.tsx | 101 + .../projects/judge-deliberation.test.tsx | 295 + .../projects/project-judge-privacy.test.tsx | 357 +- packages/api/src/projects-import.server.ts | 22 +- packages/api/src/routers/judging-scores.ts | 1459 ++ packages/api/src/routers/judging.ts | 44 +- packages/api/src/routers/projects.ts | 154 +- .../tests/integration/judging-access.test.ts | 567 + .../__snapshots__/api-surface.test.ts.snap | 18 + packages/api/src/utils/audit/coverage.ts | 5 + .../api/src/utils/judging/scoring.test.ts | 73 + packages/api/src/utils/judging/scoring.ts | 40 + packages/db/drizzle/0046_hot_zarda.sql | 113 + packages/db/drizzle/meta/0046_snapshot.json | 12721 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/scripts/dev-db-backup-sanitizer.ts | 7 + packages/db/src/schemas/knight-hacks.ts | 18 +- .../src/tests/dev-db-backup-sanitizer.test.ts | 7 + packages/db/src/tests/judging-schema.test.ts | 60 + .../db/src/tests/migration-lineage.test.ts | 6 +- packages/validators/src/audit.ts | 21 + packages/validators/src/judging.ts | 22 + packages/validators/src/projects.ts | 11 +- packages/validators/src/tests/audit.test.ts | 10 + packages/validators/src/tests/judging.test.ts | 14 + 52 files changed, 19030 insertions(+), 294 deletions(-) create mode 100644 apps/blade/src/app/_components/judging/evaluation-audit-panel.tsx create mode 100644 apps/blade/src/app/_components/judging/evaluation-dialog.tsx create mode 100644 apps/blade/src/app/_components/judging/judge-deliberation.tsx create mode 100644 apps/blade/src/app/_components/judging/judge-submissions.tsx create mode 100644 apps/blade/src/app/_components/judging/judging-configuration-panel.tsx create mode 100644 apps/blade/src/app/_components/judging/project-command-center.tsx create mode 100644 apps/blade/src/app/_components/judging/project-score-dialog.tsx create mode 100644 apps/blade/src/app/_components/projects/judge-projects-loading.tsx create mode 100644 apps/blade/src/tests/projects/admin-projects-redirect.test.ts create mode 100644 apps/blade/src/tests/projects/evaluation-audit-panel.test.tsx create mode 100644 apps/blade/src/tests/projects/judge-deliberation.test.tsx create mode 100644 packages/api/src/routers/judging-scores.ts create mode 100644 packages/api/src/utils/judging/scoring.test.ts create mode 100644 packages/api/src/utils/judging/scoring.ts create mode 100644 packages/db/drizzle/0046_hot_zarda.sql create mode 100644 packages/db/drizzle/meta/0046_snapshot.json diff --git a/.forge/features/judging-scores-and-deliberation/spec.md b/.forge/features/judging-scores-and-deliberation/spec.md index fb996de05..ad49d6378 100644 --- a/.forge/features/judging-scores-and-deliberation/spec.md +++ b/.forge/features/judging-scores-and-deliberation/spec.md @@ -20,7 +20,7 @@ This feature turns the existing project directory into the judging workspace whi `/judge/projects` has three tabs. -- `Projects` keeps the searchable project table as the primary judging view. It adds the selected challenge's rating and a judging action to each project. +- `Projects` keeps the searchable project table as the primary judging view. It adds the selected challenge's rating and a judging action to each project. Projects leave the default view after that judge submits an evaluation. A `See previously judged` toggle restores them. - `Submissions` lists the current judge's evaluations, feedback, score, challenge, and last edit time. A judge can open and edit an evaluation while judging is open. - `Deliberation` explains that private sections help a judge compare projects before award discussions. Judges can create, rename, reorder, and delete sections, add projects they have judged, and drag projects into their preferred order. @@ -34,8 +34,9 @@ The project evaluation opens in a viewport-safe dialog or mobile drawer. It show - The rubric may contain any number of quantitative items. - The rubric may contain short-response items. - The form explains who can read each short response before the judge submits it. -- If a guest response is optional-public, the guest chooses whether officers and authenticated judges may read it. It remains private to that guest otherwise. -- Authenticated member-judge responses are public to the judging team. +- If a guest response is optional-public, the guest chooses whether the project hackers receive it. The form updates the audience label as the guest changes this choice. +- Authenticated member-judge responses are always shared with the project hackers. The form states this before submission. +- Authenticated judges and officers can read submitted feedback regardless of its hacker visibility setting. A saved evaluation closes the form, updates the row, and appears in `Submissions`. Editing replaces the current response while retaining its revision history for officers. @@ -47,7 +48,10 @@ For a project in a selected challenge: - After they submit, they see the average score from all evaluations for that project and challenge. - An officer can enable `Display all results` so authenticated member judges see available scoped results before submitting. The switch never expands guest access. - Guest judges only receive scores for their room's challenge. -- Authenticated member judges may also see an `Overall rating` column. It averages every evaluation for the project across all challenge scopes. +- Guest judges do not receive the project's other challenge entries. +- Authenticated member judges see `Challenge rating` for the selected scope and `Rating` for the project across all challenge scopes. +- Authenticated member judges can sort by `Rating`. They can sort by `Challenge rating` when the officer has enabled `Display all results`. +- Authenticated member judges see a green challenge badge after a project receives its first evaluation in that challenge. General uses a darker green treatment. Empty aggregates display `(?)`, not zero. @@ -90,12 +94,11 @@ The rubric requires at least one quantitative item before judging opens. Once th ### Out of scope -- Choosing award winners or publishing results to hackers. +- Choosing award winners or delivering saved feedback to hackers. - Assigning projects to presentation time slots. - Scheduling routes through judging rooms. - Judge calibration or score normalization. - Shared deliberation boards or live collaborative sorting. -- Viewing another judge's private responses. - Deleting an evaluation. ## Vocabulary @@ -104,7 +107,7 @@ The rubric requires at least one quantitative item before judging opens. Once th - `Quantitative item`: a rubric question answered with an integer from 1 through 5. - `Short-response item`: a rubric question answered with text and an explicit visibility rule. - `Scoped rating`: the mean evaluation score for one project and one challenge. -- `Overall rating`: the mean evaluation score for one project across every challenge and judge. +- `Overall rating`: the value displayed as `Rating`, computed across every challenge and judge for the project. - `Submission`: the judge-facing record of an evaluation. - `Deliberation section`: a private named and ordered list of projects created by one judge. - `Project command center`: the officer workspace for project inventory, rubric, judging state, result visibility, and rooms. @@ -119,8 +122,10 @@ The rubric requires at least one quantitative item before judging opens. Once th - The overall rating is the arithmetic mean of all evaluation scores for the project. Every evaluation has equal weight. - A judge can submit one evaluation for the same project in each eligible challenge and can edit each evaluation while judging is open. - A judge sees `(?)` for a scoped aggregate until they submit in that scope, unless they are an authenticated member judge and an officer enabled `Display all results`. +- The default Projects view excludes projects already evaluated by the current judge in the active challenge. `See previously judged` restores them. - Guests never receive results or projects outside their configured challenge. -- The evaluation form states the audience for each short response and applies that rule on every read. +- Guest project data omits all challenge badges. Authenticated project data includes per-challenge evaluation counts for the badge completion treatment. +- The evaluation form states whether hackers receive each short response. Authenticated judges and officers can review every response. - `Submissions` shows only the current judge's evaluations and supports editing while Open. - `Deliberation` accepts only projects the current judge has evaluated. - Pointer drag, keyboard movement, and explicit move controls provide equivalent ordering behavior. diff --git a/.forge/features/judging-scores-and-deliberation/srd.md b/.forge/features/judging-scores-and-deliberation/srd.md index abd66c954..61241c977 100644 --- a/.forge/features/judging-scores-and-deliberation/srd.md +++ b/.forge/features/judging-scores-and-deliberation/srd.md @@ -72,13 +72,13 @@ Each completed evaluation has equal weight. Do not average criterion columns glo ### Short-response visibility -Each short-response rubric item stores separate policies for member and guest judges with enum values `public`, `public_optional`, and `private`. +Each short-response rubric item stores separate hacker-visibility policies for member and guest judges with enum values `public`, `public_optional`, and `private`. These values do not restrict authenticated judge or officer review. -- `public`: the judging team may read the response. -- `public_optional`: the author chooses at submission time. Default the choice to private. -- `private`: only the author and officers handling a judging dispute may read it. +- `public`: share the response with the project hackers. +- `public_optional`: the guest chooses at submission time whether to share the response with the project hackers. Default the choice to not shared. +- `private`: do not share the response with the project hackers. -The default rubric policy is `public` for member judges and `public_optional` for guests. This matches the current product rule that authenticated judge feedback is public while retaining an explicit schema for future hackathons. The command center presents both policies. For KH IX, the member policy control is fixed to `public`; changing that policy requires an explicit future product decision. +The default rubric policy is `public` for member judges and `public_optional` for guests. The command center describes these values in terms of hacker delivery. For KH IX, the member policy is fixed to `public`; changing that policy requires an explicit future product decision. Project teams do not receive short responses in this slice. @@ -140,7 +140,7 @@ Keep the existing inventory-lock fields. A missing configuration row behaves as ### Rubric -Add `JudgingRubricItem` with `id`, `hackathonId`, `kind`, `label`, `description`, `displayOrder`, `required`, `memberVisibilityPolicy`, `guestVisibilityPolicy`, and timestamps. Visibility policy columns are null for rating items and required for short-response items. Add unique `(hackathonId, displayOrder)` and same-hackathon relation checks where supported. +Add `JudgingRubricItem` with `id`, `hackathonId`, `kind`, `label`, `description`, `displayOrder`, `required`, `memberVisibilityPolicy`, `guestVisibilityPolicy`, and timestamps. Rating items are always required. Officers can configure required state for short-response items. Visibility policy columns are null for rating items and required for short-response items. Add unique `(hackathonId, displayOrder)` and same-hackathon relation checks where supported. ### Evaluations @@ -200,7 +200,9 @@ Would this require a developer change next year? - Keep route pages thin and server-first. Do not put `use client` on a page. - Pass server-read data into client feature components. Do not immediately re-fetch it with client tRPC. - Use `Tabs` with URL-backed state. Keep guest restrictions visible and disabled rather than hiding the current challenge. -- Use the existing responsive project table and mobile cards. Add `Rating` and member-only `Overall rating` columns without creating document-level horizontal overflow. +- Use the existing responsive project table and mobile cards. Add `Challenge rating` and member-only `Rating` columns without creating document-level horizontal overflow. +- Hide challenge badges and the Challenges column from guest judges. Authenticated judges keep them and see green completion states backed by per-challenge evaluation counts. +- Hide projects already evaluated by the current judge in the active challenge by default. `See previously judged` restores them without changing `Submissions`. - Use a dialog on desktop and a viewport-safe drawer or dialog on mobile for evaluation and bounded rubric editing. - Use radio groups or segmented 1 through 5 controls with visible numeric labels, 44px targets, keyboard input, and focus rings. - Show feedback visibility beside each text field and again near Submit. Do not rely on color alone. diff --git a/.forge/features/judging-scores-and-deliberation/status.md b/.forge/features/judging-scores-and-deliberation/status.md index 82c7aa620..ae50a205b 100644 --- a/.forge/features/judging-scores-and-deliberation/status.md +++ b/.forge/features/judging-scores-and-deliberation/status.md @@ -10,8 +10,9 @@ Current phase: Implementation - 2026-09-05: One evaluation belongs to one judge, project, and challenge. A judge may evaluate the same project in several challenge scopes and may edit each evaluation while judging is Open. - 2026-09-05: An evaluation score averages its quantitative answers. Scoped and overall ratings average evaluation scores with no judge calibration or per-challenge scaling. - 2026-09-05: A judge sees `(?)` until they evaluate the project in that challenge. Officers may reveal scoped results early to authenticated member judges. Guest result access never widens. -- 2026-09-05: Overall ratings appear only to authenticated member judges and officers. -- 2026-09-05: Short-response items carry public, public-optional, or private policies. KH IX member feedback is public. Guest optional-public feedback defaults private and lets the guest opt in. +- 2026-09-05: The member table labels the scoped score `Challenge rating` and the cross-challenge score `Rating`. Members can sort by the visible score data. +- 2026-09-05: Short-response policies control hacker delivery. KH IX member feedback is always shared with hackers. Guest optional-public feedback defaults to not shared and lets the guest opt in. Authenticated judges and officers can review all feedback. +- 2026-09-05: The Projects tab hides the current judge's completed projects by default and restores them with `See previously judged`. Guest views omit other challenge data. Member challenge badges turn green after the first evaluation in that scope. - 2026-09-05: Judging state is Draft, Open, or Closed. Closed is read-only and may reopen. At least one rating item is required to open. - 2026-09-05: The judge workspace tabs are `Projects`, `Submissions`, and `Deliberation`. Deliberation is private, available to guests and members, accepts judged projects, and supports accessible ordering. - 2026-09-05: The first evaluation locks rubric changes and destructive inventory replacement. Ordinary imports remain add-only by normalized Devpost URL. @@ -29,16 +30,16 @@ None. - [x] Complete reverse-prompting for `srd.md`. - [x] Complete reverse-prompting for `test-cases.md`. - [x] Record human approval for the SRD and 20 test cases. -- [ ] Add validator and score-math tests. -- [ ] Add schema and generated migration. -- [ ] Add API procedures, authorization, transactions, and audit coverage. -- [ ] Add project command center and compatibility routing. -- [ ] Add Projects, Submissions, and Deliberation judge tabs. -- [ ] Add matching loading, error, empty, desktop, and mobile states. -- [ ] Run automated checks and targeted visual verification. -- [ ] Run Forge review at depth 5 and clear all blockers. -- [ ] Sync and rebase onto current GitHub `main` once host Git access is available. -- [ ] Create and assign the GitHub issue with required labels. +- [x] Add validator and score-math tests. +- [x] Add schema and generated migration. +- [x] Add API procedures, authorization, transactions, and audit coverage. +- [x] Add project command center and compatibility routing. +- [x] Add Projects, Submissions, and Deliberation judge tabs. +- [x] Add matching loading, error, empty, desktop, and mobile states. +- [x] Run automated checks and targeted visual verification. +- [x] Run Forge review at depth 5 and clear all blockers. +- [x] Sync and rebase onto current GitHub `main` once host Git access is available. +- [x] Create and assign the GitHub issue with required labels. - [ ] Push the branch and open a fully documented PR. - [ ] Upload many screenshots to GitHub discussion only. - [ ] Address, reply to, resolve, and re-request CodeRabbit review until approved. @@ -49,10 +50,15 @@ None. - Repository history review: the retired 2025 rubric used five fixed 1 through 10 fields and separate public and private feedback. This bundle replaces that fixed shape with hackathon data. - Published KH8 Devpost review: confirmed Originality, Technical Understanding, Functionality, Design, and Wow Factor as useful seed content, not code constants. - GitHub browser review: PR #529 establishes the expected issue linking, detailed flow narrative, labels, test evidence, and externally hosted screenshots. -- GitHub CLI login attempt: blocked before device-code creation because this task shell cannot reach `github.com`. The existing CLI token is invalid. Local implementation continues while host authentication is resolved. +- GitHub CLI and Git network access: authenticated as `DVidal1205`. The feature branch is based on `origin/main` at `f4436df1`. +- Full package tests: `@forge/db` 146 passed, `@forge/api` 768 passed, `@forge/blade` 774 passed, and `@forge/validators` 267 passed. Total: 1,955 passing tests. +- Repository verification: `pnpm verify:precommit`, `pnpm --filter @forge/db with-env drizzle-kit check`, `pnpm --filter @forge/blade build`, and `git diff --check` passed. +- Depth-5 Forge review: completed security and access control, persistence and migration, API contracts, UI and accessibility, and test and product-behavior passes. All identified blockers were fixed and reverified. +- Browser verification: completed guest naming, challenge-scoped project access, hacker feedback visibility, submission history, and deliberation flows. Also checked the authenticated member workspace, score sorting, completion badges, aggregate columns, project command center, room QR controls, evaluation audit history, and 390px and 320px layouts against the KH VIII import. +- PR evidence: 15 screenshots were generated in `/tmp/forge-judging-pr`. These files stay outside the repository and will be attached through GitHub-hosted review media only. ## Links - PRs: -- Issues: +- Issues: https://github.com/KnightHacks/forge/issues/531 - Reference PR: https://github.com/KnightHacks/forge/pull/529 diff --git a/.forge/features/judging-scores-and-deliberation/test-cases.md b/.forge/features/judging-scores-and-deliberation/test-cases.md index e6e3fcd8b..705dfa97b 100644 --- a/.forge/features/judging-scores-and-deliberation/test-cases.md +++ b/.forge/features/judging-scores-and-deliberation/test-cases.md @@ -4,7 +4,7 @@ Status: Approved ## Scope -These 20 cases cover rubric configuration, judging lifecycle, member and guest evaluation access, score calculation and disclosure, editable submissions, response privacy, personal deliberation, import safety, and the combined officer command center. Scheduling, winner selection, hacker-facing publication, and collaborative deliberation are excluded. +These 20 cases cover rubric configuration, judging lifecycle, member and guest evaluation access, score calculation and disclosure, editable submissions, response hacker visibility, personal deliberation, import safety, and the combined officer command center. Scheduling, winner selection, hacker-facing feedback delivery, and collaborative deliberation are excluded. ## Test placement plan @@ -24,7 +24,7 @@ Setup: Action: -- Add three rating items and two short-response items, edit labels and descriptions, set required flags and response policies, reorder them, and save. +- Add three required rating items and two short-response items, edit labels and descriptions, set the short-response required flags and response policies, reorder them, and save. Expected observations: @@ -176,6 +176,7 @@ Expected observations: - Both see `(?)` before saving and the scoped aggregate after saving. - The API omits or nulls the hidden value rather than sending it for client-only concealment. +- The submitted project leaves that judge's default Projects view. `See previously judged` restores it without changing `Submissions`. ### TC-011: Officer reveal applies only to authenticated member judges @@ -192,6 +193,7 @@ Expected observations: - The member sees the scoped aggregate immediately. - The guest still sees `(?)` and receives no widened result data. - Disabling the setting restores the member's own-submission gate. +- Members can sort by the always-visible cross-challenge `Rating`. `Challenge rating` sorting is available only while the scoped results are revealed. ### TC-012: Empty scores display as unknown @@ -253,10 +255,10 @@ Action: Expected observations: - The form states the audience before submission. -- Member responses resolve public. +- Member responses resolve as shared with hackers. - Guest public and private items ignore tampered visibility inputs. -- Guest public-optional responses use the explicit choice and default to private. -- Unauthorized judges never receive private text. +- Guest public-optional responses use the explicit hacker-sharing choice and default to not shared. +- Authenticated judges and officers can review every response. Guests cannot read other judges' responses. ### TC-016: Judge manages private deliberation sections @@ -339,6 +341,7 @@ Expected observations: - `/admin/projects` reaches the projects section without losing bookmarked access. - Judge tabs are named `Projects`, `Submissions`, and `Deliberation`; the last includes a short purpose explanation. - Guests retain the minimal shell and fixed challenge. Members retain Blade navigation and optional room selection. +- Guest rows omit the Challenges column and challenge badges. Member rows retain them, and each badge turns green after the first evaluation in that scope. - Server HTML contains initial data, and skeletons match loaded geometry without flashing an empty client shell. - Screenshots show intentional hierarchy, readable score columns, viewport-safe evaluation forms, and no sensitive team or private-feedback data. diff --git a/apps/blade/src/app/_components/judging/evaluation-audit-panel.tsx b/apps/blade/src/app/_components/judging/evaluation-audit-panel.tsx new file mode 100644 index 000000000..aa7ea946e --- /dev/null +++ b/apps/blade/src/app/_components/judging/evaluation-audit-panel.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState } from "react"; +import { History } from "lucide-react"; + +import type { RouterOutputs } from "@forge/api"; +import { Badge } from "@forge/ui/badge"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; +import { Skeleton } from "@forge/ui/skeleton"; + +import { api } from "~/trpc/react"; + +type Evaluation = RouterOutputs["judging"]["listEvaluationAudit"][number]; + +function formatTimestamp(value: Date, timeZone: string) { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone, + }).format(value); +} + +export function EvaluationAuditPanel({ + evaluations, + timeZone, +}: { + evaluations: Evaluation[]; + timeZone: string; +}) { + const [evaluationId, setEvaluationId] = useState(null); + const revisions = api.judging.getEvaluationRevisions.useQuery( + { evaluationId: evaluationId ?? "00000000-0000-4000-8000-000000000000" }, + { enabled: evaluationId !== null }, + ); + const labels = new Map( + revisions.data?.rubric.map((item) => [item.id, item.label]), + ); + + if (evaluations.length === 0) { + return ( +
+
+ ); + } + + return ( + <> +
+
+

Evaluation history

+

+ Inspect every saved revision. This view is limited to officers. +

+
+
+ + + + + + + + + + + + + {evaluations.map((evaluation) => ( + + + + + + + + + ))} + +
ProjectChallengeJudgeUpdatedRevisionAction
+ {evaluation.projectTitle} + + {evaluation.challengeLabel} + + {evaluation.judgeDisplayName} + + {formatTimestamp(evaluation.updatedAt, timeZone)} + + {evaluation.revision} + + +
+
+
+ {evaluations.map((evaluation) => ( +
+
+

{evaluation.projectTitle}

+

+ {evaluation.challengeLabel} · {evaluation.judgeDisplayName} +

+
+
+ + {formatTimestamp(evaluation.updatedAt, timeZone)} + + Revision {evaluation.revision} +
+ +
+ ))} +
+
+ + !open && setEvaluationId(null)} + open={evaluationId !== null} + > + + + + {revisions.data?.evaluation.projectTitle ?? "Evaluation history"} + + + {revisions.data + ? `${revisions.data.evaluation.challengeLabel} · ${revisions.data.evaluation.judgeDisplayName}` + : "Loading saved revisions."} + + + {revisions.isLoading ? ( +
+ + +
+ ) : revisions.error ? ( +

+ {revisions.error.message} +

+ ) : ( +
+ {revisions.data?.revisions.map((revision) => ( +
+
+
+ Revision {revision.revision} + + {revision.actorKind === "guest" + ? "Guest judge" + : "Blade member"} + +
+ + {formatTimestamp(revision.createdAt, timeZone)} + +
+
+ {revision.ratingAnswers.map((answer) => ( +
+
+ {labels.get(answer.itemId) ?? "Rating"} +
+
+ {answer.value}/5 +
+
+ ))} +
+ {revision.responseAnswers.length ? ( +
+ {revision.responseAnswers.map((answer) => ( +
+
+

+ {labels.get(answer.itemId) ?? "Feedback"} +

+ + {answer.isPublic + ? "Shared with hackers" + : "Not shared with hackers"} + +
+

+ {answer.value || "No response"} +

+
+ ))} +
+ ) : null} +
+ ))} +
+ )} +
+
+ + ); +} diff --git a/apps/blade/src/app/_components/judging/evaluation-dialog.tsx b/apps/blade/src/app/_components/judging/evaluation-dialog.tsx new file mode 100644 index 000000000..daba8eaca --- /dev/null +++ b/apps/blade/src/app/_components/judging/evaluation-dialog.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Eye, LockKeyhole, Save } from "lucide-react"; + +import type { RouterOutputs } from "@forge/api"; +import { Alert, AlertDescription, AlertTitle } from "@forge/ui/alert"; +import { Button } from "@forge/ui/button"; +import { Checkbox } from "@forge/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; +import { Label } from "@forge/ui/label"; +import { RadioGroup, RadioGroupItem } from "@forge/ui/radio-group"; +import { Textarea } from "@forge/ui/textarea"; +import { toast } from "@forge/ui/toast"; + +import { api } from "~/trpc/react"; + +type Workspace = RouterOutputs["judging"]["getWorkspace"]; +type Submission = RouterOutputs["judging"]["listMySubmissions"][number]; + +export interface EvaluationProject { + id: string; + title: string; +} + +function policyCopy( + policy: "private" | "public" | "public_optional" | null, + shared: boolean, +) { + if (policy === "public") { + return { + description: + "The hackers who submitted this project will receive this response.", + label: "Shared with hackers", + }; + } + if (policy === "public_optional") { + return shared + ? { + description: + "The hackers who submitted this project will receive this response.", + label: "Shared with hackers", + } + : { + description: + "The hackers who submitted this project will not receive this response.", + label: "Not shared with hackers", + }; + } + return { + description: + "The hackers who submitted this project will not receive this response.", + label: "Not shared with hackers", + }; +} + +export function EvaluationDialog({ + challengeLabel, + onOpenChange, + open, + project, + submission, + workspace, +}: { + challengeLabel: string; + onOpenChange: (open: boolean) => void; + open: boolean; + project: EvaluationProject; + submission?: Submission; + workspace: Workspace; +}) { + const [ratings, setRatings] = useState>(() => + Object.fromEntries( + (submission?.ratings ?? []).map((answer) => [ + answer.itemId, + answer.value, + ]), + ), + ); + const [responses, setResponses] = useState>(() => + Object.fromEntries( + (submission?.responses ?? []).map((answer) => [ + answer.itemId, + answer.value, + ]), + ), + ); + const [shared, setShared] = useState>(() => + Object.fromEntries( + (submission?.responses ?? []).map((answer) => [ + answer.itemId, + answer.isPublic, + ]), + ), + ); + const [saveError, setSaveError] = useState(null); + const router = useRouter(); + const save = api.judging.saveEvaluation.useMutation(); + const ratingItems = useMemo( + () => workspace.rubric.filter((item) => item.kind === "rating"), + [workspace.rubric], + ); + const responseItems = useMemo( + () => workspace.rubric.filter((item) => item.kind === "short_response"), + [workspace.rubric], + ); + const challengeName = /challenge$/i.test(challengeLabel) + ? challengeLabel + : `${challengeLabel} Challenge`; + + const activeProject = project; + + async function submit() { + const missingRating = ratingItems.some((item) => !ratings[item.id]); + const missingResponse = responseItems.some( + (item) => item.required && !responses[item.id]?.trim(), + ); + if (missingRating || missingResponse) { + const message = "Complete every required rubric item before saving."; + setSaveError(message); + toast.error(message); + return; + } + setSaveError(null); + try { + await save.mutateAsync({ + challengeId: workspace.challengeId, + hackathonId: workspace.hackathonId, + projectId: activeProject.id, + ratings: ratingItems.map((item) => ({ + itemId: item.id, + value: ratings[item.id] ?? 1, + })), + responses: responseItems + .filter((item) => item.required || responses[item.id]?.trim()) + .map((item) => ({ + isPublic: + (workspace.principalKind === "guest" + ? item.guestVisibilityPolicy + : item.memberVisibilityPolicy) === "public_optional" + ? shared[item.id] === true + : undefined, + itemId: item.id, + value: responses[item.id]?.trim() ?? "", + })), + }); + toast.success(submission ? "Submission updated." : "Score submitted."); + onOpenChange(false); + router.refresh(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Could not save your score."; + setSaveError(message); + toast.error(message); + } + } + + return ( + + + + + {submission ? "Edit" : "Judge"} {project.title} + + + Judging for the {challengeName} + + +
+ {workspace.state !== "open" ? ( + + + Judging is {workspace.state} + + {workspace.state === "closed" + ? "Saved submissions are read-only until an officer reopens judging." + : "An officer must open judging before scores can be submitted."} + + + ) : null} + + {responseItems.length ? ( + + + + {workspace.principalKind === "guest" + ? "Choose what hackers receive" + : "Your feedback is shared with hackers"} + + + {workspace.principalKind === "guest" + ? "Each written response below shows whether the hackers who submitted this project will receive it. Authenticated judges and officers can review every response." + : "Every written response you submit will be shared with the hackers who submitted this project. Other authenticated judges and officers can also review it."} + + + ) : null} + + {ratingItems.map((item) => ( +
+ + {item.label} * + + {item.description ? ( +

+ {item.description} +

+ ) : null} + + setRatings((current) => ({ + ...current, + [item.id]: Number(value), + })) + } + value={ratings[item.id]?.toString() ?? ""} + > + {[1, 2, 3, 4, 5].map((value) => ( + + ))} + +
+ Needs work + Exceptional +
+
+ ))} + + {responseItems.map((item) => { + const policy = + workspace.principalKind === "guest" + ? item.guestVisibilityPolicy + : item.memberVisibilityPolicy; + const visibility = policyCopy(policy, shared[item.id] === true); + return ( +
+ + {item.description ? ( +

+ {item.description} +

+ ) : null} +