docs: PR 1 implementation plan — jury preferences filter
Step-by-step plan for §E. Single-procedure change to filter getOnboardingContext memberships by linked-round type, plus a new test file covering review-only, LIVE_FINAL-only, and mixed group cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
399
docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md
Normal file
399
docs/superpowers/plans/2026-04-28-pr1-jury-preferences-filter.md
Normal file
@@ -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 <unwanted-file>`.)
|
||||||
|
|
||||||
|
- [ ] **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) <noreply@anthropic.com>
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user