diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 1e07ad5..ee70c07 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -51,6 +51,7 @@ import { deliberationRouter } from './deliberation' import { resultLockRouter } from './resultLock' // Grand-finale logistics import { finalistRouter } from './finalist' +import { logisticsRouter } from './logistics' /** * Root tRPC router that combines all domain routers @@ -108,6 +109,7 @@ export const appRouter = router({ resultLock: resultLockRouter, // Grand-finale logistics finalist: finalistRouter, + logistics: logisticsRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/routers/logistics.ts b/src/server/routers/logistics.ts new file mode 100644 index 0000000..b00b484 --- /dev/null +++ b/src/server/routers/logistics.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { router, adminProcedure } from '../trpc' +import { logAudit } from '../utils/audit' + +export const logisticsRouter = router({ + /** Read the hotel for a program (1:1). Null if not yet set. */ + getHotel: adminProcedure + .input(z.object({ programId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.hotel.findUnique({ where: { programId: input.programId } }) + }), + + /** Create or update the program's hotel. Empty link strings are stored as null. */ + upsertHotel: adminProcedure + .input( + z.object({ + programId: z.string(), + name: z.string().min(1).max(200), + address: z.string().max(500).optional(), + link: z + .string() + .url() + .or(z.literal('')) + .optional(), + notes: z.string().max(2000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const link = input.link && input.link.trim().length > 0 ? input.link : null + const hotel = await ctx.prisma.hotel.upsert({ + where: { programId: input.programId }, + create: { + programId: input.programId, + name: input.name, + address: input.address ?? null, + link, + notes: input.notes ?? null, + }, + update: { + name: input.name, + address: input.address ?? null, + link, + notes: input.notes ?? null, + }, + }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'HOTEL_UPSERT', + entityType: 'Hotel', + entityId: hotel.id, + detailsJson: { programId: input.programId, name: input.name }, + }) + return hotel + }), +}) diff --git a/tests/unit/logistics-hotel.test.ts b/tests/unit/logistics-hotel.test.ts new file mode 100644 index 0000000..f56d04d --- /dev/null +++ b/tests/unit/logistics-hotel.test.ts @@ -0,0 +1,99 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers' +import { logisticsRouter } from '../../src/server/routers/logistics' + +describe('logistics.getHotel + upsertHotel', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.hotel.deleteMany({ where: { programId } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('getHotel returns null when no hotel set', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `hotel-empty-${uid()}` }) + programIds.push(program.id) + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const hotel = await caller.getHotel({ programId: program.id }) + expect(hotel).toBeNull() + }) + + it('upsertHotel creates a hotel on first call', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `hotel-create-${uid()}` }) + programIds.push(program.id) + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const hotel = await caller.upsertHotel({ + programId: program.id, + name: 'Hotel Hermitage', + address: 'Square Beaumarchais, 98000 Monaco', + link: 'https://hotelhermitagemontecarlo.com', + notes: 'Adjacent to the venue', + }) + expect(hotel.name).toBe('Hotel Hermitage') + expect(hotel.programId).toBe(program.id) + expect(hotel.link).toContain('hermitage') + }) + + it('upsertHotel updates the existing hotel on second call', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `hotel-update-${uid()}` }) + programIds.push(program.id) + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + await caller.upsertHotel({ + programId: program.id, + name: 'Hotel A', + }) + const updated = await caller.upsertHotel({ + programId: program.id, + name: 'Hotel B', + notes: 'Changed our mind', + }) + expect(updated.name).toBe('Hotel B') + expect(updated.notes).toBe('Changed our mind') + // Still 1:1 — no second hotel row + const count = await prisma.hotel.count({ where: { programId: program.id } }) + expect(count).toBe(1) + }) + + it('upsertHotel normalizes empty link string to null', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `hotel-empty-link-${uid()}` }) + programIds.push(program.id) + const caller = createCaller(logisticsRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const hotel = await caller.upsertHotel({ + programId: program.id, + name: 'No-link Hotel', + link: '', + }) + expect(hotel.link).toBeNull() + }) +})