ddec3a927a
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
});
|