import { CreateCountrySchema, CreateMetroCitySchema, UpdateCountrySchema, UpdateMetroCitySchema, } from "@planarchy/shared"; import { Prisma } from "@planarchy/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; /** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */ function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { if (val === null || val === undefined) return Prisma.JsonNull; return val as Prisma.InputJsonValue; } export const countryRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { return ctx.db.country.findMany({ where: { ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), }, include: { metroCities: { orderBy: { name: "asc" } } }, orderBy: { name: "asc" }, }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const country = await findUniqueOrThrow( ctx.db.country.findUnique({ where: { id: input.id }, include: { metroCities: { orderBy: { name: "asc" } }, _count: { select: { resources: true } }, }, }), "Country", ); return country; }), create: adminProcedure .input(CreateCountrySchema) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.country.findUnique({ where: { code: input.code } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.code}" already exists` }); } const created = await ctx.db.country.create({ data: { code: input.code, name: input.name, dailyWorkingHours: input.dailyWorkingHours, ...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}), }, include: { metroCities: true }, }); void createAuditEntry({ db: ctx.db, entityType: "Country", entityId: created.id, entityName: created.name, action: "CREATE", userId: ctx.dbUser?.id, after: created as unknown as Record, source: "ui", }); return created; }), update: adminProcedure .input(z.object({ id: z.string(), data: UpdateCountrySchema })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.country.findUnique({ where: { id: input.id } }), "Country", ); if (input.data.code && input.data.code !== existing.code) { const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } }); if (conflict) { throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.data.code}" already exists` }); } } const before = existing as unknown as Record; const updated = await ctx.db.country.update({ where: { id: input.id }, data: { ...(input.data.code !== undefined ? { code: input.data.code } : {}), ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.data.dailyWorkingHours } : {}), ...(input.data.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.data.scheduleRules) } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), }, include: { metroCities: true }, }); void createAuditEntry({ db: ctx.db, entityType: "Country", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before, after: updated as unknown as Record, source: "ui", }); return updated; }), // ─── Metro City ───────────────────────────────────────────── createCity: adminProcedure .input(CreateMetroCitySchema) .mutation(async ({ ctx, input }) => { await findUniqueOrThrow( ctx.db.country.findUnique({ where: { id: input.countryId } }), "Country", ); const created = await ctx.db.metroCity.create({ data: { name: input.name, countryId: input.countryId }, }); void createAuditEntry({ db: ctx.db, entityType: "MetroCity", entityId: created.id, entityName: created.name, action: "CREATE", userId: ctx.dbUser?.id, after: created as unknown as Record, source: "ui", }); return created; }), updateCity: adminProcedure .input(z.object({ id: z.string(), data: UpdateMetroCitySchema })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.metroCity.findUnique({ where: { id: input.id } }), "Metro city", ); const before = existing as unknown as Record; const updated = await ctx.db.metroCity.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}) }, }); void createAuditEntry({ db: ctx.db, entityType: "MetroCity", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before, after: updated as unknown as Record, source: "ui", }); return updated; }), deleteCity: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const city = await findUniqueOrThrow( ctx.db.metroCity.findUnique({ where: { id: input.id }, include: { _count: { select: { resources: true } } }, }), "Metro city", ); if (city._count.resources > 0) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Cannot delete metro city assigned to ${city._count.resources} resource(s)`, }); } await ctx.db.metroCity.delete({ where: { id: input.id } }); void createAuditEntry({ db: ctx.db, entityType: "MetroCity", entityId: city.id, entityName: city.name, action: "DELETE", userId: ctx.dbUser?.id, before: city as unknown as Record, source: "ui", }); return { success: true }; }), });