import { prisma } from "@planarchy/db"; import { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared"; import { initTRPC, TRPCError } from "@trpc/server"; import { ZodError } from "zod"; // Minimal Session type to avoid next-auth peer-dep in this package interface Session { user?: { email?: string | null; name?: string | null; image?: string | null } | null; expires: string; } // ─── Context ────────────────────────────────────────────────────────────────── export interface TRPCContext { session: Session | null; db: typeof prisma; dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null; } export function createTRPCContext(opts: { session: Session | null; dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null; }): TRPCContext { return { session: opts.session, db: prisma, dbUser: opts.dbUser ?? null, }; } // ─── tRPC Init ─────────────────────────────────────────────────────────────── const t = initTRPC.context().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); // ─── Procedures ────────────────────────────────────────────────────────────── export const createTRPCRouter = t.router; export const createCallerFactory = t.createCallerFactory; /** * Public procedure — no authentication required. */ export const publicProcedure = t.procedure; /** * Protected procedure — requires authenticated session AND a valid DB user record. * This prevents stale sessions from accessing data after the DB user is deleted. */ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); } if (!ctx.dbUser) { throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" }); } return next({ ctx: { ...ctx, session: ctx.session, user: ctx.session.user, dbUser: ctx.dbUser, }, }); }); /** * Manager procedure — requires MANAGER or ADMIN role. */ export const managerProcedure = protectedProcedure.use(({ ctx, next }) => { const user = ctx.dbUser; if (!user) throw new TRPCError({ code: "UNAUTHORIZED" }); const allowedRoles: string[] = [SystemRole.ADMIN, SystemRole.MANAGER]; if (!allowedRoles.includes(user.systemRole)) { throw new TRPCError({ code: "FORBIDDEN", message: "Manager or Admin role required" }); } const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null ); return next({ ctx: { ...ctx, user, permissions } }); }); /** * Controller procedure — requires CONTROLLER, MANAGER, or ADMIN role. * Grants read-only access to financial and export data. */ export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => { const user = ctx.dbUser; if (!user) throw new TRPCError({ code: "UNAUTHORIZED" }); const allowed: SystemRole[] = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER]; if (!allowed.includes(user.systemRole as SystemRole)) { throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required" }); } const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null ); return next({ ctx: { ...ctx, user, permissions } }); }); /** * Admin procedure — requires ADMIN role only. */ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => { const user = ctx.dbUser; if (!user || user.systemRole !== SystemRole.ADMIN) { throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" }); } const permissions = resolvePermissions(SystemRole.ADMIN, null); return next({ ctx: { ...ctx, user, permissions } }); }); /** * requirePermission — throws FORBIDDEN if the ctx lacks the given permission. */ export function requirePermission( ctx: { permissions: Set }, key: PermissionKey ): void { if (!ctx.permissions.has(key)) { throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` }); } }