diff --git a/docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md b/docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md new file mode 100644 index 0000000..e9c7e4b --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-pr5-settings-consolidation.md @@ -0,0 +1,142 @@ +# 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** + +```ts +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** + +```ts +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 + 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 logistics** — `defaultAttendeeCap` (number input), `attendeeEditCutoffHours` (number input with "hours before LIVE_FINAL" hint), `confirmationWindowHours` (number input with "hours from email send" hint). +2. **Visa** — `visaStatusVisibleToMembers` 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 `` and `` 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 `` and `` 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 vitest** — `npx 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. diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index 4792cc4..2eb5de1 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -281,4 +281,114 @@ export const programRouter = router({ return { success: true } }), + + /** + * Returns the merged edition-settings view for the admin Settings page: + * Program fields (defaultAttendeeCap, visaStatusVisibleToMembers) plus the + * LIVE_FINAL round's configJson values (attendeeEditCutoffHours and + * confirmationWindowHours, with sensible defaults). Round-derived values + * are null when the LIVE_FINAL round doesn't exist yet. + */ + 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 + 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, + } + }), + + /** + * Partial update for edition settings. Writes Program fields directly and + * merges round-config keys (attendeeEditCutoffHours, confirmationWindowHours) + * into the LIVE_FINAL round's configJson, preserving any unrelated keys + * already in the JSON blob. + */ + updateEditionSettings: adminProcedure + .input( + z.object({ + programId: z.string(), + defaultAttendeeCap: z.number().int().min(1).max(20).optional(), + visaStatusVisibleToMembers: z.boolean().optional(), + attendeeEditCutoffHours: z.number().int().min(0).max(720).optional(), + confirmationWindowHours: z.number().int().min(1).max(720).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const programData: Record = {} + if (input.defaultAttendeeCap !== undefined) { + programData.defaultAttendeeCap = input.defaultAttendeeCap + } + if (input.visaStatusVisibleToMembers !== undefined) { + programData.visaStatusVisibleToMembers = input.visaStatusVisibleToMembers + } + + if (Object.keys(programData).length > 0) { + await ctx.prisma.program.update({ + where: { id: input.programId }, + data: programData, + }) + } + + const roundConfigKeys = ['attendeeEditCutoffHours', 'confirmationWindowHours'] as const + const roundUpdates: Record = {} + for (const k of roundConfigKeys) { + const v = input[k] + if (v !== undefined) roundUpdates[k] = v + } + if (Object.keys(roundUpdates).length > 0) { + const round = await ctx.prisma.round.findFirst({ + where: { + competition: { programId: input.programId }, + roundType: 'LIVE_FINAL', + }, + orderBy: { sortOrder: 'desc' }, + select: { id: true, configJson: true }, + }) + if (round) { + const existing = (round.configJson ?? {}) as Record + const merged = { ...existing, ...roundUpdates } + await ctx.prisma.round.update({ + where: { id: round.id }, + data: { configJson: merged as Prisma.InputJsonValue }, + }) + } + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'PROGRAM_EDITION_SETTINGS_UPDATE', + entityType: 'Program', + entityId: input.programId, + detailsJson: { ...input }, + }) + + return { ok: true } + }), }) diff --git a/tests/unit/program-edition-settings.test.ts b/tests/unit/program-edition-settings.test.ts new file mode 100644 index 0000000..21cc4a7 --- /dev/null +++ b/tests/unit/program-edition-settings.test.ts @@ -0,0 +1,177 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestCompetition, + createTestRound, + cleanupTestData, + uid, +} from '../helpers' +import { programRouter } from '../../src/server/routers/program' + +describe('program.getEditionSettings', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('returns the merged view (program fields + LIVE_FINAL round config)', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ + name: `edition-merged-${uid()}`, + defaultAttendeeCap: 5, + }) + programIds.push(program.id) + await prisma.program.update({ + where: { id: program.id }, + data: { visaStatusVisibleToMembers: false }, + }) + const competition = await createTestCompetition(program.id) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + configJson: { attendeeEditCutoffHours: 24, confirmationWindowHours: 12 }, + }) + + const caller = createCaller(programRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.getEditionSettings({ programId: program.id }) + expect(result.defaultAttendeeCap).toBe(5) + expect(result.visaStatusVisibleToMembers).toBe(false) + expect(result.attendeeEditCutoffHours).toBe(24) + expect(result.confirmationWindowHours).toBe(12) + expect(result.liveFinalRoundId).not.toBeNull() + }) + + it('falls back to defaults when LIVE_FINAL round has no relevant config', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `edition-defaults-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id) + await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + configJson: {}, + }) + + const caller = createCaller(programRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.getEditionSettings({ programId: program.id }) + expect(result.attendeeEditCutoffHours).toBe(48) + expect(result.confirmationWindowHours).toBe(24) + }) + + it('returns nulls for round-derived fields when no LIVE_FINAL round exists', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `edition-noround-${uid()}` }) + programIds.push(program.id) + + const caller = createCaller(programRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const result = await caller.getEditionSettings({ programId: program.id }) + expect(result.attendeeEditCutoffHours).toBeNull() + expect(result.confirmationWindowHours).toBeNull() + expect(result.liveFinalRoundId).toBeNull() + }) +}) + +describe('program.updateEditionSettings', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('writes program fields + round configJson + audit-logs', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `edition-update-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id) + const round = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + }) + + const caller = createCaller(programRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.updateEditionSettings({ + programId: program.id, + defaultAttendeeCap: 4, + visaStatusVisibleToMembers: false, + attendeeEditCutoffHours: 36, + confirmationWindowHours: 18, + }) + + const refreshed = await prisma.program.findUniqueOrThrow({ where: { id: program.id } }) + expect(refreshed.defaultAttendeeCap).toBe(4) + expect(refreshed.visaStatusVisibleToMembers).toBe(false) + + const updatedRound = await prisma.round.findUniqueOrThrow({ where: { id: round.id } }) + const cfg = updatedRound.configJson as Record + expect(cfg.attendeeEditCutoffHours).toBe(36) + expect(cfg.confirmationWindowHours).toBe(18) + + const audit = await prisma.auditLog.findFirst({ + where: { action: 'PROGRAM_EDITION_SETTINGS_UPDATE', entityId: program.id }, + }) + expect(audit).not.toBeNull() + }) + + it('preserves untouched configJson keys', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `edition-preserve-${uid()}` }) + programIds.push(program.id) + const competition = await createTestCompetition(program.id) + const round = await createTestRound(competition.id, { + roundType: 'LIVE_FINAL', + sortOrder: 99, + configJson: { foo: 'bar', attendeeEditCutoffHours: 48 }, + }) + + const caller = createCaller(programRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.updateEditionSettings({ + programId: program.id, + attendeeEditCutoffHours: 24, + }) + + const updated = await prisma.round.findUniqueOrThrow({ where: { id: round.id } }) + const cfg = updated.configJson as Record + expect(cfg.foo).toBe('bar') + expect(cfg.attendeeEditCutoffHours).toBe(24) + }) +})