Files
MOPC-Portal/docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md
Matt 78992a493a feat: program.getEditionSettings + updateEditionSettings
Backs the new consolidated Edition tab on /admin/settings.

getEditionSettings returns a merged view of Program-level fields
(defaultAttendeeCap, visaStatusVisibleToMembers) plus LIVE_FINAL round
config (attendeeEditCutoffHours, confirmationWindowHours, with
sensible defaults). Round-derived values are null when the round
doesn't exist yet.

updateEditionSettings is partial — only supplied fields are written.
Round config writes merge into the existing configJson so other keys
are preserved. Audit-logged as PROGRAM_EDITION_SETTINGS_UPDATE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:59:31 +02:00

6.2 KiB

PR 5: Settings Consolidation Implementation Plan

For agentic workers: Steps use checkbox (- [ ]) syntax for tracking.

Goal: Consolidate per-edition logistics configuration onto /admin/settings so admins find every knob in one place. Remove dead Logistics tabs and move the visa-visibility toggle out of the Visas tab.

Architecture: A new "Edition" tab on /admin/settings reads + writes a merged view: Program fields (defaultAttendeeCap, visaStatusVisibleToMembers) + the LIVE_FINAL round's configJson (attendeeEditCutoffHours, confirmationWindowHours). One reader (program.getEditionSettings) + one writer (program.updateEditionSettings) so the UI stays simple.

Tech Stack: Prisma 6 (no migration — purely a UX layer over existing fields), tRPC, Vitest 4 for the procedure tests, shadcn/ui for the tab + sub-sections.


Task 1: tRPC procedures for edition settings (TDD)

Files:

  • Modify: src/server/routers/program.ts

  • Create: tests/unit/program-edition-settings.test.ts

  • Step 1: Failing tests

describe('program.getEditionSettings', () => {
  it('returns the merged view (program fields + LIVE_FINAL round config)', async () => {
    // setup: program with defaultAttendeeCap=5, visaStatusVisibleToMembers=false
    // + LIVE_FINAL round with configJson { attendeeEditCutoffHours: 24, confirmationWindowHours: 12 }
    // assert: shape { defaultAttendeeCap, visaStatusVisibleToMembers, attendeeEditCutoffHours, confirmationWindowHours }
  })

  it('falls back to defaults when LIVE_FINAL round has no config', async () => {
    // assert: attendeeEditCutoffHours = 48, confirmationWindowHours = 24
  })

  it('returns nulls for round-derived fields if no LIVE_FINAL round exists', async () => {
    // assert: attendeeEditCutoffHours = null, confirmationWindowHours = null
  })
})

describe('program.updateEditionSettings', () => {
  it('writes program fields + round configJson + audit-logs', async () => {
    // call with partial { defaultAttendeeCap: 4, attendeeEditCutoffHours: 36 }
    // assert: program.defaultAttendeeCap=4, round.configJson.attendeeEditCutoffHours=36
    // assert: AuditLog action=PROGRAM_EDITION_SETTINGS_UPDATE
  })

  it('preserves untouched configJson keys', async () => {
    // round.configJson initially { foo: 'bar', attendeeEditCutoffHours: 48 }
    // call with { attendeeEditCutoffHours: 24 }
    // assert: round.configJson = { foo: 'bar', attendeeEditCutoffHours: 24 }
  })
})
  • Step 2: Run failing tests.

  • Step 3: Implement getEditionSettings

getEditionSettings: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(async ({ ctx, input }) => {
    const program = await ctx.prisma.program.findUniqueOrThrow({
      where: { id: input.programId },
      select: { id: true, defaultAttendeeCap: true, visaStatusVisibleToMembers: true },
    })
    const round = await ctx.prisma.round.findFirst({
      where: { competition: { programId: input.programId }, roundType: 'LIVE_FINAL' },
      orderBy: { sortOrder: 'desc' },
      select: { id: true, configJson: true },
    })
    const cfg = (round?.configJson ?? {}) as Record<string, unknown>
    return {
      programId: program.id,
      defaultAttendeeCap: program.defaultAttendeeCap,
      visaStatusVisibleToMembers: program.visaStatusVisibleToMembers,
      liveFinalRoundId: round?.id ?? null,
      attendeeEditCutoffHours: round
        ? ((cfg.attendeeEditCutoffHours as number | undefined) ?? 48)
        : null,
      confirmationWindowHours: round
        ? ((cfg.confirmationWindowHours as number | undefined) ?? 24)
        : null,
    }
  }),
  • Step 4: Implement updateEditionSettings with merge semantics on configJson + audit log.

  • Step 5: Run tests, expect green.

  • Step 6: Commit.


Task 2: Edition Settings tab UI

Files:

  • Create: src/components/admin/settings/edition-settings-tab.tsx

  • Modify: src/components/settings/settings-content.tsx (add the new tab + entry)

  • Step 1: Build the Edition Settings tab

Three sub-sections (Card per section):

  1. Grand-finale logisticsdefaultAttendeeCap (number input), attendeeEditCutoffHours (number input with "hours before LIVE_FINAL" hint), confirmationWindowHours (number input with "hours from email send" hint).
  2. VisavisaStatusVisibleToMembers Switch + caption.
  3. Coming soon — placeholder for Lunch + Email Templates that PRs 6 + 7 will fill.

Each input fires program.updateEditionSettings.useMutation debounced or on-blur. Toast on success.

  • Step 2: Wire into /admin/settings — add <TabsTrigger value="edition"> and <TabsContent value="edition"> in settings-content. Place before existing tabs.

  • Step 3: Live smoke — open settings, verify it renders, toggle visa visibility, verify it persists in the DB and the applicant dashboard hides the surface.

  • Step 4: Commit.


Task 3: Cleanup — remove dead Logistics tabs + visibility toggle

Files:

  • Modify: src/app/(admin)/admin/logistics/page.tsx

  • Modify: src/components/admin/logistics/visas-tab.tsx

  • Step 1: Remove disabled tabs

Drop the <TabsTrigger value="documents" disabled> and <TabsTrigger value="settings" disabled> blocks. Also drop their unused imports (FileText, Settings).

  • Step 2: Replace visibility toggle with a hint

In visas-tab.tsx, swap the inline Switch + Label for a small banner: "Configure visibility in Settings → Edition" with a Link to /admin/settings?tab=edition. Drop the getVisaVisibility query and setVisaVisibility mutation usage from this component (keep the procedures — used by the new settings page).

  • Step 3: Live smoke — confirm Logistics tab bar shows only the active tabs, visas tab no longer has a toggle, settings page does.

  • Step 4: Commit.


Task 4: Final verification

  • Step 1: Full vitestnpx vitest run. Expect 161 + new tests (~5).
  • Step 2: Typecheck — clean.
  • Step 3: Build — clean.
  • Step 4: E2E smoke — open /admin/settings → Edition tab, change knobs, verify persistence; confirm the Visas tab no longer shows the toggle and the hint links to settings.