feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export { appRouter, type AppRouter } from "./router/index.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
|
||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||
|
||||
@@ -116,10 +116,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
const temperature = settings?.aiTemperature ?? 0.7;
|
||||
const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini";
|
||||
|
||||
// 2. Resolve granular permissions
|
||||
// 2. Resolve granular permissions (using DB-based role defaults if available)
|
||||
const permissions = resolvePermissions(
|
||||
userRole as SystemRole,
|
||||
(ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
const permissionList = [...permissions];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resourceRouter } from "./resource.js";
|
||||
import { roleRouter } from "./role.js";
|
||||
import { settingsRouter } from "./settings.js";
|
||||
import { staffingRouter } from "./staffing.js";
|
||||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||||
import { timelineRouter } from "./timeline.js";
|
||||
import { userRouter } from "./user.js";
|
||||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
||||
chargeabilityReport: chargeabilityReportRouter,
|
||||
calculationRule: calculationRuleRouter,
|
||||
computationGraph: computationGraphRouter,
|
||||
systemRoleConfig: systemRoleConfigRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -220,7 +220,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
/** Get task counts for the current user */
|
||||
/** Get task counts for the current user — single groupBy instead of 5 counts */
|
||||
taskCounts: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
const now = new Date();
|
||||
@@ -230,11 +230,12 @@ export const notificationRouter = createTRPCRouter({
|
||||
category: { in: ["TASK" as const, "APPROVAL" as const] },
|
||||
};
|
||||
|
||||
const [open, inProgress, done, dismissed, overdue] = await Promise.all([
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "OPEN" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "IN_PROGRESS" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DONE" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DISMISSED" } }),
|
||||
const [grouped, overdue] = await Promise.all([
|
||||
ctx.db.notification.groupBy({
|
||||
by: ["taskStatus"],
|
||||
where,
|
||||
_count: true,
|
||||
}),
|
||||
ctx.db.notification.count({
|
||||
where: {
|
||||
...where,
|
||||
@@ -244,7 +245,18 @@ export const notificationRouter = createTRPCRouter({
|
||||
}),
|
||||
]);
|
||||
|
||||
return { open, inProgress, done, dismissed, overdue };
|
||||
const counts: Record<string, number> = {};
|
||||
for (const g of grouped) {
|
||||
if (g.taskStatus) counts[g.taskStatus] = g._count;
|
||||
}
|
||||
|
||||
return {
|
||||
open: counts["OPEN"] ?? 0,
|
||||
inProgress: counts["IN_PROGRESS"] ?? 0,
|
||||
done: counts["DONE"] ?? 0,
|
||||
dismissed: counts["DISMISSED"] ?? 0,
|
||||
overdue,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Update task status */
|
||||
|
||||
@@ -291,6 +291,54 @@ export const resourceRouter = createTRPCRouter({
|
||||
return { resources, total, page, limit, nextCursor };
|
||||
}),
|
||||
|
||||
/** Lightweight resource card for hover tooltips on the timeline. */
|
||||
getHoverCard: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
availability: true,
|
||||
isActive: true,
|
||||
areaRole: { select: { id: true, name: true, color: true } },
|
||||
country: { select: { name: true, code: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
resourceType: true,
|
||||
},
|
||||
});
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
const anon = anonymizeResource(resource, directory);
|
||||
return {
|
||||
id: anon.id,
|
||||
displayName: anon.displayName ?? "",
|
||||
eid: anon.eid ?? "",
|
||||
chapter: resource.chapter,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
currency: resource.currency,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
skills: resource.skills as Record<string, unknown>[],
|
||||
isActive: resource.isActive,
|
||||
resourceType: resource.resourceType,
|
||||
areaRole: resource.areaRole,
|
||||
country: resource.country,
|
||||
managementLevel: resource.managementLevel,
|
||||
};
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
hasDalleApiKey: !!settings?.azureDalleApiKey,
|
||||
// Vacation defaults
|
||||
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
|
||||
// Timeline
|
||||
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -94,6 +96,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
azureDalleApiKey: z.string().optional(),
|
||||
// Vacation
|
||||
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
|
||||
// Timeline
|
||||
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -144,6 +148,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
data.azureDalleApiKey = input.azureDalleApiKey || null;
|
||||
// Vacation
|
||||
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
|
||||
// Timeline
|
||||
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
|
||||
|
||||
await ctx.db.systemSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { adminProcedure, createTRPCRouter, invalidateRoleDefaultsCache, protectedProcedure } from "../trpc.js";
|
||||
|
||||
export const systemRoleConfigRouter = createTRPCRouter({
|
||||
/** List all role configs (sorted by sortOrder) */
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.systemRoleConfig.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
}),
|
||||
|
||||
/** Update a role's default permissions, label, description, and color */
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
role: z.string(),
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
defaultPermissions: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const data: Record<string, unknown> = {};
|
||||
if (input.label !== undefined) data.label = input.label;
|
||||
if (input.description !== undefined) data.description = input.description;
|
||||
if (input.color !== undefined) data.color = input.color;
|
||||
if (input.defaultPermissions !== undefined) data.defaultPermissions = input.defaultPermissions;
|
||||
|
||||
const result = await ctx.db.systemRoleConfig.update({
|
||||
where: { role: input.role as never },
|
||||
data,
|
||||
});
|
||||
|
||||
// Invalidate cached role defaults so changes take effect immediately
|
||||
invalidateRoleDefaultsCache();
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -721,6 +721,182 @@ export const timelineRouter = createTRPCRouter({
|
||||
return allocation;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch quick-assign multiple resources to a project for a date range.
|
||||
* Used by the multi-selection floating action bar.
|
||||
*/
|
||||
batchQuickAssign: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
assignments: z
|
||||
.array(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).default(8),
|
||||
role: z.string().min(1).max(200).default("Team Member"),
|
||||
status: z
|
||||
.nativeEnum(AllocationStatus)
|
||||
.default(AllocationStatus.PROPOSED),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
// Validate all date ranges
|
||||
for (const a of input.assignments) {
|
||||
if (a.endDate < a.startDate) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "End date must be after start date",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = await ctx.db.$transaction(async (tx) => {
|
||||
const created = [];
|
||||
for (const a of input.assignments) {
|
||||
const percentage = Math.min(
|
||||
100,
|
||||
Math.round((a.hoursPerDay / 8) * 100),
|
||||
);
|
||||
const metadata = {
|
||||
source: "batchQuickAssign",
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const assignment = await createAssignment(
|
||||
tx as unknown as Parameters<typeof createAssignment>[0],
|
||||
{
|
||||
resourceId: a.resourceId,
|
||||
projectId: a.projectId,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
percentage,
|
||||
role: a.role,
|
||||
status: a.status,
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
created.push(assignment);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// Fire SSE events
|
||||
for (const assignment of results) {
|
||||
emitAllocationCreated({
|
||||
id: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: results.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch-shift multiple allocations by the same number of days.
|
||||
* Used by multi-select drag on the timeline.
|
||||
*/
|
||||
batchShiftAllocations: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
allocationIds: z.array(z.string()).min(1).max(100),
|
||||
daysDelta: z.number().int().min(-3650).max(3650),
|
||||
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
if (input.daysDelta === 0) return { count: 0 };
|
||||
|
||||
// Load all allocations
|
||||
const entries = await Promise.all(
|
||||
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
|
||||
);
|
||||
const resolved = entries.filter(
|
||||
(e): e is NonNullable<typeof e> => e !== null,
|
||||
);
|
||||
|
||||
if (resolved.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
|
||||
}
|
||||
|
||||
const results = await ctx.db.$transaction(async (tx) => {
|
||||
const updated = [];
|
||||
for (const entry of resolved) {
|
||||
const existing = entry.entry;
|
||||
const newStart = new Date(existing.startDate);
|
||||
const newEnd = new Date(existing.endDate);
|
||||
|
||||
if (input.mode === "move") {
|
||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
||||
} else if (input.mode === "resize-start") {
|
||||
newStart.setDate(newStart.getDate() + input.daysDelta);
|
||||
// Clamp: start must not exceed end
|
||||
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
|
||||
} else {
|
||||
// resize-end
|
||||
newEnd.setDate(newEnd.getDate() + input.daysDelta);
|
||||
// Clamp: end must not precede start
|
||||
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
|
||||
}
|
||||
|
||||
const result = await updateAllocationEntry(
|
||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||
{
|
||||
id: existing.id,
|
||||
demandRequirementUpdate: {
|
||||
startDate: newStart,
|
||||
endDate: newEnd,
|
||||
},
|
||||
assignmentUpdate: {
|
||||
startDate: newStart,
|
||||
endDate: newEnd,
|
||||
},
|
||||
},
|
||||
);
|
||||
updated.push(result.allocation);
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: "Allocation",
|
||||
entityId: input.allocationIds.join(","),
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
operation: "batchShift",
|
||||
mode: input.mode,
|
||||
daysDelta: input.daysDelta,
|
||||
count: resolved.length,
|
||||
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Fire SSE events
|
||||
for (const alloc of results) {
|
||||
emitAllocationUpdated({
|
||||
id: alloc.id,
|
||||
projectId: alloc.projectId,
|
||||
resourceId: alloc.resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: results.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get budget status for a project.
|
||||
*/
|
||||
|
||||
@@ -12,9 +12,21 @@ import { Prisma } from "@planarchy/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.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: {
|
||||
@@ -23,11 +35,23 @@ export const userRouter = createTRPCRouter({
|
||||
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({
|
||||
|
||||
@@ -15,16 +15,47 @@ export interface TRPCContext {
|
||||
session: Session | null;
|
||||
db: typeof prisma;
|
||||
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults: Record<string, PermissionKey[]> | null;
|
||||
}
|
||||
|
||||
// Cache role defaults for 60 seconds to avoid DB hit on every request
|
||||
let _roleDefaultsCache: Record<string, PermissionKey[]> | null = null;
|
||||
let _roleDefaultsCacheTime = 0;
|
||||
const ROLE_DEFAULTS_TTL = 60_000;
|
||||
|
||||
export async function loadRoleDefaults(): Promise<Record<string, PermissionKey[]>> {
|
||||
const now = Date.now();
|
||||
if (_roleDefaultsCache && now - _roleDefaultsCacheTime < ROLE_DEFAULTS_TTL) {
|
||||
return _roleDefaultsCache;
|
||||
}
|
||||
const configs = await prisma.systemRoleConfig.findMany({
|
||||
select: { role: true, defaultPermissions: true },
|
||||
});
|
||||
const map: Record<string, PermissionKey[]> = {};
|
||||
for (const c of configs) {
|
||||
map[c.role] = c.defaultPermissions as PermissionKey[];
|
||||
}
|
||||
_roleDefaultsCache = map;
|
||||
_roleDefaultsCacheTime = now;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Invalidate the role defaults cache (call after updating SystemRoleConfig) */
|
||||
export function invalidateRoleDefaultsCache(): void {
|
||||
_roleDefaultsCache = null;
|
||||
_roleDefaultsCacheTime = 0;
|
||||
}
|
||||
|
||||
export function createTRPCContext(opts: {
|
||||
session: Session | null;
|
||||
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
|
||||
roleDefaults?: Record<string, PermissionKey[]> | null;
|
||||
}): TRPCContext {
|
||||
return {
|
||||
session: opts.session,
|
||||
db: prisma,
|
||||
dbUser: opts.dbUser ?? null,
|
||||
roleDefaults: opts.roleDefaults ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,7 +117,8 @@ export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
}
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
@@ -104,7 +136,8 @@ export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
}
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
|
||||
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
@@ -117,7 +150,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (!user || user.systemRole !== SystemRole.ADMIN) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
|
||||
}
|
||||
const permissions = resolvePermissions(SystemRole.ADMIN, null);
|
||||
const permissions = resolvePermissions(SystemRole.ADMIN, null, ctx.roleDefaults ?? undefined);
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ model User {
|
||||
dashboardLayout Json? @db.JsonB
|
||||
columnPreferences Json? @db.JsonB
|
||||
favoriteProjectIds Json? @db.JsonB // string[] of project IDs
|
||||
lastLoginAt DateTime?
|
||||
lastActiveAt DateTime?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
@@ -1359,6 +1361,8 @@ model Notification {
|
||||
@@index([userId, category, taskStatus])
|
||||
@@index([nextRemindAt])
|
||||
@@index([assigneeId, taskStatus])
|
||||
@@index([category, nextRemindAt])
|
||||
@@index([userId, dueDate])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
@@ -1390,6 +1394,22 @@ model NotificationBroadcast {
|
||||
@@map("notification_broadcasts")
|
||||
}
|
||||
|
||||
// ─── System Role Configuration ────────────────────────────────────────────────
|
||||
|
||||
model SystemRoleConfig {
|
||||
role SystemRole @id
|
||||
label String // Display label, e.g. "Manager"
|
||||
description String? // Optional description of the role
|
||||
defaultPermissions Json @db.JsonB // PermissionKey[] — default permissions for this role
|
||||
color String? // Badge color, e.g. "purple", "blue"
|
||||
sortOrder Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("system_role_configs")
|
||||
}
|
||||
|
||||
// ─── System Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
model SystemSettings {
|
||||
@@ -1419,6 +1439,8 @@ model SystemSettings {
|
||||
anonymizationAliases Json? @db.JsonB
|
||||
// Vacation defaults
|
||||
vacationDefaultDays Int? @default(28) // default annual entitlement
|
||||
// Timeline undo
|
||||
timelineUndoMaxSteps Int? @default(50) // max undo history depth
|
||||
// DALL-E image generation (Azure requires separate deployment)
|
||||
azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name
|
||||
azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat)
|
||||
|
||||
@@ -49,9 +49,12 @@ export const ROLE_DEFAULT_PERMISSIONS: Record<SystemRole, PermissionKey[]> = {
|
||||
|
||||
export function resolvePermissions(
|
||||
systemRole: SystemRole,
|
||||
overrides?: PermissionOverrides | null
|
||||
overrides?: PermissionOverrides | null,
|
||||
/** Optional DB-based defaults per role. If provided, takes precedence over ROLE_DEFAULT_PERMISSIONS. */
|
||||
roleDefaults?: Record<string, PermissionKey[]>,
|
||||
): Set<PermissionKey> {
|
||||
const base = new Set<PermissionKey>(ROLE_DEFAULT_PERMISSIONS[systemRole] ?? []);
|
||||
const defaults = roleDefaults?.[systemRole] ?? ROLE_DEFAULT_PERMISSIONS[systemRole] ?? [];
|
||||
const base = new Set<PermissionKey>(defaults);
|
||||
if (overrides?.granted) {
|
||||
for (const p of overrides.granted) base.add(p);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user