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