diff --git a/docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md b/docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md new file mode 100644 index 0000000..3dbab7e --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md @@ -0,0 +1,399 @@ +# PR 1 — Jury Preferences Filter (§E) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Filter the juror "Confirm Your Evaluation Preferences" banner so it only shows jury group memberships whose linked rounds include at least one review-type round (INTAKE/FILTERING/EVALUATION/SUBMISSION/MENTORING). Memberships in groups whose only rounds are LIVE_FINAL or DELIBERATION must be hidden — those ceremonies don't use cap+category preferences. + +**Architecture:** Single-procedure change. `getOnboardingContext` in `src/server/routers/user.ts` adds a Prisma `juryGroup.rounds: { some: { roundType: { in: [...] } } }` filter to the `juryGroupMember.findMany` query. No schema migration. No frontend change (the banner consumes the same return shape). + +**Tech Stack:** Prisma 6, tRPC 11, Vitest 4. Tests use `prisma` directly + `createCaller(userRouter, user)` from `tests/setup.ts`. + +**Spec:** `docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md` §E. + +--- + +## File map + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/server/routers/user.ts` (`getOnboardingContext`, lines 1395-1422) | Modify | Add `juryGroup.rounds.some` filter to membership query | +| `tests/unit/jury-preferences-filter.test.ts` | Create | Three test cases covering the filter behavior | + +No new files beyond the test. No schema changes. No client change. + +--- + +## Task 1: Orient on the current implementation + +**Files:** +- Read: `src/server/routers/user.ts:1395-1422` +- Read: `src/components/jury/preferences-banner.tsx:17-62` +- Read: `prisma/schema.prisma` (lines 2249-2280 for `JuryGroup`, lines 2149-2200 for `Round`) + +- [ ] **Step 1: Read the current procedure** + +```bash +sed -n '1395,1425p' /Users/matt/Repos/MOPC/src/server/routers/user.ts +``` + +Expected: see the `getOnboardingContext: protectedProcedure.query(...)` definition that calls `prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id }, include: { juryGroup: { select: ... } } })`. + +- [ ] **Step 2: Confirm the JuryGroup ↔ Round relation field** + +```bash +sed -n '2249,2280p' /Users/matt/Repos/MOPC/prisma/schema.prisma +``` + +Expected: see `model JuryGroup { ... rounds Round[] ... }`. The relation field name is **`rounds`** (plural). This is the field name we'll use in the Prisma `where` filter. + +- [ ] **Step 3: Inspect the consumer to confirm return shape stays identical** + +```bash +sed -n '17,62p' /Users/matt/Repos/MOPC/src/components/jury/preferences-banner.tsx +``` + +Expected: see that the banner reads `(ctx?.memberships ?? []).filter(m => m.selfServiceCap === null)`. We are only narrowing the rows returned — the row shape is unchanged — so the banner needs no edit. + +--- + +## Task 2: Write the failing tests + +**Files:** +- Create: `tests/unit/jury-preferences-filter.test.ts` + +- [ ] **Step 1: Create the test file** + +Write the file at `tests/unit/jury-preferences-filter.test.ts`: + +```ts +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, createTestProgram, createTestCompetition, createTestRound, + cleanupTestData, uid, +} from '../helpers' +import { userRouter } from '../../src/server/routers/user' + +describe('user.getOnboardingContext — preferences filter excludes LIVE_FINAL/DELIBERATION-only groups', () => { + let programId: string + let competitionId: string + let juror: { id: string; email: string; role: 'JURY_MEMBER' } + let observerOnlyGroupId: string + let reviewGroupId: string + let mixedGroupId: string + const userIds: string[] = [] + + beforeAll(async () => { + const program = await createTestProgram({ name: `prefs-filter-${uid()}` }) + programId = program.id + const competition = await createTestCompetition(programId) + competitionId = competition.id + + const reviewRound = await createTestRound(competitionId, { + name: 'Review Round', slug: `review-${uid()}`, roundType: 'EVALUATION', sortOrder: 0, + }) + const liveFinalRound = await createTestRound(competitionId, { + name: 'Final Round', slug: `final-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 1, + }) + const deliberationRound = await createTestRound(competitionId, { + name: 'Delib Round', slug: `delib-${uid()}`, roundType: 'DELIBERATION', sortOrder: 2, + }) + + const reviewOnlyGroup = await prisma.juryGroup.create({ + data: { + id: uid('jg-rev'), competitionId, name: 'Review Only Group', + slug: uid('rev'), defaultMaxAssignments: 30, + }, + }) + reviewGroupId = reviewOnlyGroup.id + const liveFinalOnlyGroup = await prisma.juryGroup.create({ + data: { + id: uid('jg-fin'), competitionId, name: 'Finals Only Group', + slug: uid('fin'), defaultMaxAssignments: 10, + }, + }) + observerOnlyGroupId = liveFinalOnlyGroup.id + const mixedGroup = await prisma.juryGroup.create({ + data: { + id: uid('jg-mix'), competitionId, name: 'Mixed Group', + slug: uid('mix'), defaultMaxAssignments: 20, + }, + }) + mixedGroupId = mixedGroup.id + + await prisma.round.update({ where: { id: reviewRound.id }, data: { juryGroupId: reviewOnlyGroup.id } }) + await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } }) + const mixedReview = await createTestRound(competitionId, { + name: 'Mixed Review', slug: `mixed-rev-${uid()}`, roundType: 'EVALUATION', sortOrder: 3, + }) + const mixedFinal = await createTestRound(competitionId, { + name: 'Mixed Final', slug: `mixed-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 4, + }) + await prisma.round.update({ where: { id: mixedReview.id }, data: { juryGroupId: mixedGroup.id } }) + await prisma.round.update({ where: { id: mixedFinal.id }, data: { juryGroupId: mixedGroup.id } }) + + void deliberationRound // referenced for cleanup; not attached to a group in these scenarios + + const u = await createTestUser('JURY_MEMBER') + userIds.push(u.id) + juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' } + + await prisma.juryGroupMember.createMany({ + data: [ + { id: uid('jgm-rev'), juryGroupId: reviewGroupId, userId: u.id, role: 'MEMBER' }, + { id: uid('jgm-fin'), juryGroupId: observerOnlyGroupId, userId: u.id, role: 'MEMBER' }, + { id: uid('jgm-mix'), juryGroupId: mixedGroupId, userId: u.id, role: 'MEMBER' }, + ], + }) + }) + + afterAll(async () => { + await cleanupTestData(programId, userIds) + }) + + it('returns the review-only group membership', async () => { + const caller = createCaller(userRouter, juror) + const ctx = await caller.getOnboardingContext() + const names = ctx.memberships.map((m) => m.juryGroupName).sort() + expect(names).toContain('Review Only Group') + }) + + it('omits the LIVE_FINAL-only group membership', async () => { + const caller = createCaller(userRouter, juror) + const ctx = await caller.getOnboardingContext() + const names = ctx.memberships.map((m) => m.juryGroupName) + expect(names).not.toContain('Finals Only Group') + }) + + it('keeps the mixed group (has at least one review round)', async () => { + const caller = createCaller(userRouter, juror) + const ctx = await caller.getOnboardingContext() + const names = ctx.memberships.map((m) => m.juryGroupName) + expect(names).toContain('Mixed Group') + }) + + it('returns hasSelfServiceOptions=true when at least one membership remains', async () => { + const caller = createCaller(userRouter, juror) + const ctx = await caller.getOnboardingContext() + expect(ctx.hasSelfServiceOptions).toBe(true) + expect(ctx.memberships.length).toBe(2) + }) +}) + +describe('user.getOnboardingContext — juror with only LIVE_FINAL membership', () => { + let programId: string + let juror: { id: string; email: string; role: 'JURY_MEMBER' } + const userIds: string[] = [] + + beforeAll(async () => { + const program = await createTestProgram({ name: `prefs-only-fin-${uid()}` }) + programId = program.id + const competition = await createTestCompetition(programId) + const liveFinalRound = await createTestRound(competition.id, { + name: 'Solo Final', slug: `solo-fin-${uid()}`, roundType: 'LIVE_FINAL', sortOrder: 0, + }) + const liveFinalOnlyGroup = await prisma.juryGroup.create({ + data: { + id: uid('jg-only-fin'), competitionId: competition.id, name: 'Solo Finals Group', + slug: uid('solo-fin'), defaultMaxAssignments: 10, + }, + }) + await prisma.round.update({ where: { id: liveFinalRound.id }, data: { juryGroupId: liveFinalOnlyGroup.id } }) + + const u = await createTestUser('JURY_MEMBER') + userIds.push(u.id) + juror = { id: u.id, email: u.email, role: 'JURY_MEMBER' } + await prisma.juryGroupMember.create({ + data: { id: uid('jgm-only-fin'), juryGroupId: liveFinalOnlyGroup.id, userId: u.id, role: 'MEMBER' }, + }) + }) + + afterAll(async () => { + await cleanupTestData(programId, userIds) + }) + + it('returns no memberships and hasSelfServiceOptions=false', async () => { + const caller = createCaller(userRouter, juror) + const ctx = await caller.getOnboardingContext() + expect(ctx.memberships).toEqual([]) + expect(ctx.hasSelfServiceOptions).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run the new tests and confirm they FAIL** + +```bash +cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30 +``` + +Expected: at least one of these failures: +- "omits the LIVE_FINAL-only group membership" → `expected [...] not to contain 'Finals Only Group'` (today the procedure returns ALL memberships, so it WILL contain that name). +- "returns no memberships and hasSelfServiceOptions=false" → `expected [{ ... 'Solo Finals Group' ... }] to equal []` (today returns the lone Finals membership). + +If all four tests pass with no code change, STOP — that means the filter is already in place or the test fixtures aren't exercising the procedure correctly. Re-read Task 1 outputs. + +--- + +## Task 3: Apply the Prisma filter + +**Files:** +- Modify: `src/server/routers/user.ts` (the `findMany` call inside `getOnboardingContext`) + +- [ ] **Step 1: Read the current procedure to anchor the edit** + +```bash +sed -n '1397,1410p' /Users/matt/Repos/MOPC/src/server/routers/user.ts +``` + +Expected: lines look like + +```ts + getOnboardingContext: protectedProcedure.query(async ({ ctx }) => { + const memberships = await ctx.prisma.juryGroupMember.findMany({ + where: { userId: ctx.user.id }, + include: { + juryGroup: { + select: { + id: true, + name: true, + defaultMaxAssignments: true, + }, + }, + }, + }) +``` + +- [ ] **Step 2: Add the round-type filter to the `where` clause** + +Edit `src/server/routers/user.ts`. Replace the `findMany` call's `where` clause: + +```ts +// before +where: { userId: ctx.user.id }, + +// after +where: { + userId: ctx.user.id, + juryGroup: { + rounds: { + some: { + roundType: { + in: ['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING'], + }, + }, + }, + }, +}, +``` + +(The `include` block stays unchanged. The `return` block stays unchanged.) + +- [ ] **Step 3: Re-run the tests and confirm they all PASS** + +```bash +cd /Users/matt/Repos/MOPC && npx vitest run tests/unit/jury-preferences-filter.test.ts 2>&1 | tail -30 +``` + +Expected: 5 passing, 0 failing across the two `describe` blocks. + +If any test fails: +- Re-read the procedure: did the edit save? `sed -n '1397,1415p' src/server/routers/user.ts` +- Did the relation field name change? Re-confirm via `grep "rounds " prisma/schema.prisma` +- Did the test cleanup run from a previous failed test leave stale data? Try `npx vitest run -t 'returns the review-only group membership'` in isolation. + +--- + +## Task 4: Run the full unit suite to check for regressions + +- [ ] **Step 1: Run all unit tests** + +```bash +cd /Users/matt/Repos/MOPC && npx vitest run tests/unit 2>&1 | tail -20 +``` + +Expected: all unit tests pass. The new file should appear in the output as `tests/unit/jury-preferences-filter.test.ts ... ✓`. No previously-passing test should now fail. + +If any other test fails: read the failure. The most likely cause is that the Prisma filter unintentionally hides memberships from a test fixture that happens to use a jury group with no attached rounds. If so, the test fixture (not our change) is the problem — flag it and fix the fixture to attach a review-type round. + +--- + +## Task 5: Run typecheck + +- [ ] **Step 1: Run the project typecheck** + +```bash +cd /Users/matt/Repos/MOPC && npm run typecheck 2>&1 | tail -10 +``` + +Expected: `tsc --noEmit` exits with code 0, no output. + +--- + +## Task 6: Commit + +- [ ] **Step 1: Stage the changes** + +```bash +cd /Users/matt/Repos/MOPC && git add src/server/routers/user.ts tests/unit/jury-preferences-filter.test.ts +``` + +- [ ] **Step 2: Verify staged diff is what we expect** + +```bash +cd /Users/matt/Repos/MOPC && git diff --cached --stat +``` + +Expected: +``` + src/server/routers/user.ts | ~10 +- + tests/unit/jury-preferences-filter.test.ts | ~140 ++++ + 2 files changed, ~150 insertions(+), ~3 deletions(-) +``` + +(Numbers approximate. If anything else is staged, unstage it: `git restore --staged `.) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/matt/Repos/MOPC && git commit -m "$(cat <<'EOF' +fix: filter juror preferences banner to review-round groups + +The "Confirm Your Evaluation Preferences" banner was including jury +group memberships whose only rounds are LIVE_FINAL or DELIBERATION. +Those ceremonies don't use cap+category preferences, so the sliders +were meaningless. Filter getOnboardingContext to memberships in +groups with at least one INTAKE/FILTERING/EVALUATION/SUBMISSION/ +MENTORING round. + +Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §E + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 4: Verify clean status** + +```bash +cd /Users/matt/Repos/MOPC && git status --short && git log -1 --oneline +``` + +Expected: empty status, latest commit is the one just created. + +--- + +## Acceptance criteria + +- [ ] `npx vitest run tests/unit/jury-preferences-filter.test.ts` → 5 pass +- [ ] `npx vitest run tests/unit` → no regressions +- [ ] `npm run typecheck` → no errors +- [ ] Commit message references §E of the spec +- [ ] No frontend changes +- [ ] No Prisma migration files changed + +## Out of scope (verified) + +- The `preferences-banner.tsx` component is NOT modified — the return shape from `getOnboardingContext` is unchanged, only the row count differs. +- Existing tests are NOT modified — the change is additive. +- Prisma schema is NOT touched.