import { PermissionOverrides, SystemRole, resolvePermissions, type ColumnPreferences, } from "@planarchy/shared/types"; import { dashboardLayoutSchema, normalizeDashboardLayout, } from "@planarchy/shared/schemas"; import { Prisma } from "@planarchy/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; export const userRouter = createTRPCRouter({ /** Lightweight user list for task assignment (ADMIN + MANAGER) */ listAssignable: managerProcedure.query(async ({ ctx }) => { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, }, orderBy: { name: "asc" }, }); }), list: adminProcedure.query(async ({ ctx }) => { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, systemRole: true, createdAt: true, lastLoginAt: true, lastActiveAt: true, permissionOverrides: true, }, orderBy: { name: "asc" }, }); }), /** Count of users active in the last 5 minutes */ activeCount: adminProcedure.query(async ({ ctx }) => { const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); const count = await ctx.db.user.count({ where: { lastActiveAt: { gte: fiveMinAgo } }, }); return { count }; }), me: protectedProcedure.query(async ({ ctx }) => { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, createdAt: true, }, }), "User", ); return user; }), create: adminProcedure .input( z.object({ email: z.string().email(), name: z.string().min(1), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), password: z.string().min(8), }), ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { email: input.email } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); const user = await ctx.db.user.create({ data: { email: input.email, name: input.name, systemRole: input.systemRole, passwordHash, }, select: { id: true, name: true, email: true, systemRole: true }, }); // Auto-link to a resource with matching email (if one exists and isn't already linked) const matchingResource = await ctx.db.resource.findFirst({ where: { email: input.email, userId: null }, select: { id: true }, }); if (matchingResource) { await ctx.db.resource.update({ where: { id: matchingResource.id }, data: { userId: user.id }, }); } return user; }), updateRole: adminProcedure .input( z.object({ id: z.string(), systemRole: z.nativeEnum(SystemRole), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.user.update({ where: { id: input.id }, data: { systemRole: input.systemRole }, select: { id: true, name: true, email: true, systemRole: true }, }); }), // ─── Resource Linking ────────────────────────────────────────────────── linkResource: adminProcedure .input(z.object({ userId: z.string(), resourceId: z.string().nullable() })) .mutation(async ({ ctx, input }) => { if (input.resourceId) { // Unlink any resource previously linked to this user await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null }, }); // Link the new resource await ctx.db.resource.update({ where: { id: input.resourceId }, data: { userId: input.userId }, }); } else { // Unlink await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null }, }); } return { success: true }; }), autoLinkAllByEmail: adminProcedure.mutation(async ({ ctx }) => { // Find all users without a linked resource, then match by email const unlinkedUsers = await ctx.db.user.findMany({ where: { resource: null }, select: { id: true, email: true }, }); let linked = 0; for (const user of unlinkedUsers) { const resource = await ctx.db.resource.findFirst({ where: { email: user.email, userId: null }, select: { id: true }, }); if (resource) { await ctx.db.resource.update({ where: { id: resource.id }, data: { userId: user.id }, }); linked++; } } return { linked, checked: unlinkedUsers.length }; }), getDashboardLayout: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, select: { dashboardLayout: true, updatedAt: true }, }); return { layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null, updatedAt: user?.updatedAt ?? null, }; }), saveDashboardLayout: protectedProcedure .input(z.object({ layout: dashboardLayoutSchema })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.user.update({ where: { email: ctx.session.user?.email ?? "" }, data: { dashboardLayout: input.layout as unknown as import("@planarchy/db").Prisma.InputJsonValue }, select: { updatedAt: true }, }); return { updatedAt: updated.updatedAt }; }), // ─── Favorite Projects ────────────────────────────────────────────────── getFavoriteProjectIds: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { favoriteProjectIds: true }, }); return ((user?.favoriteProjectIds as string[] | null) ?? []) as string[]; }), toggleFavoriteProject: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { favoriteProjectIds: true }, }); const current = ((user?.favoriteProjectIds as string[] | null) ?? []) as string[]; const next = current.includes(input.projectId) ? current.filter((id) => id !== input.projectId) : [...current, input.projectId]; await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { favoriteProjectIds: next as unknown as Prisma.InputJsonValue }, }); return { favoriteProjectIds: next, added: !current.includes(input.projectId) }; }), setPermissions: adminProcedure .input( z.object({ userId: z.string(), overrides: z .object({ granted: z.array(z.string()).optional(), denied: z.array(z.string()).optional(), chapterIds: z.array(z.string()).optional(), }) .nullable(), }), ) .mutation(async ({ ctx, input }) => { const user = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, }); return user; }), resetPermissions: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: Prisma.DbNull }, }); }), getColumnPreferences: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { columnPreferences: true }, }); return (user?.columnPreferences ?? {}) as ColumnPreferences; }), setColumnPreferences: protectedProcedure .input(z.object({ view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]), visible: z.array(z.string()).optional(), sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(), rowOrder: z.array(z.string()).nullable().optional(), })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { columnPreferences: true }, }); const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences; const prev = (prefs[input.view] as import("@planarchy/shared").ViewPreferences | undefined) ?? { visible: [] }; // Merge: only overwrite fields that were explicitly provided const merged: import("@planarchy/shared").ViewPreferences = { visible: input.visible ?? prev.visible, }; // sort: null = clear, undefined = keep existing, value = set if (input.sort !== null && input.sort !== undefined) { merged.sort = input.sort; } else if (input.sort === undefined && prev.sort != null) { merged.sort = prev.sort; } // rowOrder: null = clear, undefined = keep existing, value = set if (input.rowOrder !== null && input.rowOrder !== undefined) { merged.rowOrder = input.rowOrder; } else if (input.rowOrder === undefined && prev.rowOrder != null) { merged.rowOrder = prev.rowOrder; } prefs[input.view] = merged; await ctx.db.user.update({ where: { id: ctx.dbUser!.id }, data: { columnPreferences: prefs as Prisma.InputJsonValue }, }); return { ok: true }; }), getEffectivePermissions: adminProcedure .input(z.object({ userId: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.db.user.findUniqueOrThrow({ where: { id: input.userId }, select: { systemRole: true, permissionOverrides: true }, }); const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as PermissionOverrides | null, ); return { systemRole: user.systemRole, effectivePermissions: Array.from(permissions), overrides: user.permissionOverrides as PermissionOverrides | null, }; }), });