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>
This commit is contained in:
Matt
2026-04-28 19:59:31 +02:00
parent 62ab27a05a
commit 78992a493a
3 changed files with 429 additions and 0 deletions

View File

@@ -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<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 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 `<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 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.

View File

@@ -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<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,
}
}),
/**
* 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<string, unknown> = {}
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<string, number> = {}
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<string, unknown>
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 }
}),
})

View File

@@ -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<string, unknown>
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<string, unknown>
expect(cfg.foo).toBe('bar')
expect(cfg.attendeeEditCutoffHours).toBe(24)
})
})