# 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.
