feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+409 -89
View File
@@ -5,7 +5,7 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
@@ -19,11 +19,92 @@ import { logger } from "../lib/logger.js";
const MAX_TOOL_ITERATIONS = 8;
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
export const ASSISTANT_CONFIRMATION_PREFIX = "CONFIRMATION_REQUIRED:";
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
const MAX_OPENAI_TOOL_DEFINITIONS = 128;
const ALWAYS_INCLUDED_TOOL_NAMES = new Set([
"get_current_user",
"search_resources",
"get_resource",
"search_projects",
"get_project",
"list_allocations",
"get_statistics",
"navigate_to_page",
]);
const MUTATION_INTENT_KEYWORDS = [
"create", "add", "new", "update", "change", "edit", "delete", "remove", "cancel", "approve", "reject",
"anlegen", "erstellen", "neu", "aendern", "ändern", "bearbeiten", "loeschen", "löschen", "entfernen",
"stornieren", "genehmigen", "ablehnen", "setzen",
];
const TOOL_SELECTION_HINTS = [
{
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
nameFragments: ["holiday", "vacation", "entitlement"],
exactTools: ["list_holidays_by_region", "get_resource_holidays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
},
{
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
nameFragments: ["resource", "skill", "role", "user", "staffing", "capacity"],
exactTools: ["search_resources", "get_resource", "search_by_skill", "check_resource_availability", "get_staffing_suggestions", "find_capacity"],
},
{
keywords: ["capacity", "availability", "available", "kapazitaet", "kapazität", "verfuegbar", "verfügbar", "auslastung", "chargeability", "sah", "lcr"],
nameFragments: ["capacity", "availability", "chargeability", "staffing", "rate", "budget"],
exactTools: ["check_resource_availability", "get_staffing_suggestions", "find_capacity", "get_chargeability", "find_best_project_resource", "resolve_rate"],
},
{
keywords: ["project", "projects", "projekt", "projekte", "allocation", "allocations", "allokation", "allokationen", "assignment", "assignments", "demand", "demands", "timeline"],
nameFragments: ["project", "allocation", "demand", "timeline", "assignment", "blueprint"],
exactTools: ["search_projects", "get_project", "list_allocations", "list_demands", "get_timeline_entries_view", "get_project_timeline_context"],
},
{
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
},
{
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
nameFragments: ["estimate", "budget", "rate", "cost"],
exactTools: ["get_budget_status", "list_rate_cards", "resolve_rate", "lookup_rate", "search_estimates", "get_estimate_detail"],
},
{
keywords: ["notification", "notifications", "benachrichtigung", "benachrichtigungen", "task", "tasks", "aufgabe", "aufgaben", "reminder", "reminders", "broadcast"],
nameFragments: ["notification", "task", "reminder", "broadcast"],
exactTools: ["list_notifications", "get_unread_notification_count", "list_tasks", "get_task_counts", "list_reminders", "get_broadcast_detail"],
},
{
keywords: ["country", "countries", "land", "laender", "länder", "city", "cities", "stadt", "staedte", "städte", "region", "regions", "state", "bundesland"],
nameFragments: ["country", "metro_city", "holiday_calendar"],
exactTools: ["list_countries", "get_country", "list_holidays_by_region", "list_holiday_calendars"],
},
{
keywords: ["user", "users", "permission", "permissions", "rolle", "rollen", "admin", "system", "webhook", "import", "audit", "history", "rechte"],
nameFragments: ["user", "permission", "role", "system", "webhook", "import", "audit", "history", "org_unit", "country"],
exactTools: ["list_users", "get_effective_user_permissions", "list_audit_log_entries", "query_change_history", "get_system_settings", "list_webhooks"],
},
];
const TOOL_SELECTION_STOP_WORDS = new Set([
"the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "who", "how",
"und", "der", "die", "das", "ein", "eine", "einer", "einem", "einen", "mit", "von", "fuer", "für",
"auf", "ist", "sind", "im", "in", "am", "an", "zu", "zum", "zur", "mir", "bitte", "can", "you",
"mir", "alle", "all", "den", "dem", "des",
]);
type ChatMessage = { role: "user" | "assistant"; content: string };
type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
class AssistantApprovalStorageUnavailableError extends Error {
constructor() {
super("Assistant approval storage is unavailable.");
this.name = "AssistantApprovalStorageUnavailableError";
}
}
export interface PendingAssistantApproval {
id: string;
userId: string;
@@ -83,29 +164,32 @@ Datenmodell:
- Projekte: ShortCode, Budget (Cent), Win-Probability, Status (DRAFT/ACTIVE/ON_HOLD/COMPLETED/CANCELLED)
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
- Urlaub: Typen ANNUAL/SICK/OTHER/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED. PUBLIC_HOLIDAY wird nicht manuell beantragt, sondern über Feiertagskalender verwaltet.
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
list_users: PermissionKey.MANAGE_USERS,
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
deactivate_resource: "manageResources",
create_role: "manageResources",
update_role: "manageResources",
delete_role: "manageResources",
create_org_unit: "manageResources",
update_org_unit: "manageResources",
create_role: PermissionKey.MANAGE_ROLES,
update_role: PermissionKey.MANAGE_ROLES,
delete_role: PermissionKey.MANAGE_ROLES,
// Project management
update_project: "manageProjects",
create_project: "manageProjects",
delete_project: "manageProjects",
create_client: "manageProjects",
update_client: "manageProjects",
create_estimate: "manageProjects",
clone_estimate: "manageProjects",
update_estimate_draft: "manageProjects",
submit_estimate_version: "manageProjects",
approve_estimate_version: "manageProjects",
create_estimate_revision: "manageProjects",
create_estimate_export: "manageProjects",
generate_estimate_weekly_phasing: "manageProjects",
update_estimate_commercial_terms: "manageProjects",
generate_project_cover: "manageProjects",
remove_project_cover: "manageProjects",
import_csv_data: PermissionKey.IMPORT_DATA,
@@ -120,15 +204,9 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
batch_shift_timeline_allocations: "manageAllocations",
create_demand: "manageAllocations",
fill_demand: "manageAllocations",
create_estimate_planning_handoff: "manageAllocations",
// Vacation management
create_vacation: "manageVacations",
approve_vacation: "manageVacations",
reject_vacation: "manageVacations",
cancel_vacation: "manageVacations",
set_entitlement: "manageVacations",
// Task management
create_task_for_user: "manageProjects",
send_broadcast: "manageProjects",
execute_task_action: "manageAllocations",
};
@@ -142,6 +220,7 @@ const COST_TOOLS = new Set([
"resolve_rate",
"list_rate_cards",
"get_estimate_detail",
"get_estimate_version_snapshot",
"find_best_project_resource",
]);
@@ -158,22 +237,90 @@ const CONTROLLER_ONLY_TOOLS = new Set([
"get_chargeability_report",
"get_resource_computation_graph",
"get_project_computation_graph",
"get_estimate_detail",
"list_estimate_versions",
"get_estimate_version_snapshot",
"get_estimate_weekly_phasing",
"get_estimate_commercial_terms",
]);
/** Tools that follow managerProcedure access rules in the main API. */
const MANAGER_ONLY_TOOLS = new Set([
"import_csv_data",
"list_assignable_users",
"create_notification",
"update_timeline_allocation_inline",
"apply_timeline_project_shift",
"quick_assign_timeline_resource",
"batch_quick_assign_timeline_resources",
"batch_shift_timeline_allocations",
"create_estimate",
"clone_estimate",
"update_estimate_draft",
"submit_estimate_version",
"approve_estimate_version",
"create_estimate_revision",
"create_estimate_export",
"create_estimate_planning_handoff",
"generate_estimate_weekly_phasing",
"update_estimate_commercial_terms",
"create_task_for_user",
"assign_task",
"send_broadcast",
"list_broadcasts",
"get_broadcast_detail",
"approve_vacation",
"reject_vacation",
"get_pending_vacation_approvals",
"get_entitlement_summary",
"set_entitlement",
"create_role",
"update_role",
"delete_role",
"create_client",
"update_client",
]);
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
const ADMIN_ONLY_TOOLS = new Set([
"list_users",
"get_active_user_count",
"create_user",
"set_user_password",
"update_user_role",
"update_user_name",
"link_user_resource",
"auto_link_users_by_email",
"set_user_permissions",
"reset_user_permissions",
"get_effective_user_permissions",
"disable_user_totp",
"list_dispo_import_batches",
"get_dispo_import_batch",
"stage_dispo_import_batch",
"validate_dispo_import_batch",
"cancel_dispo_import_batch",
"list_dispo_staged_resources",
"list_dispo_staged_projects",
"list_dispo_staged_assignments",
"list_dispo_staged_vacations",
"list_dispo_staged_unresolved_records",
"resolve_dispo_staged_record",
"commit_dispo_import_batch",
"get_system_settings",
"update_system_settings",
"test_ai_connection",
"test_smtp_connection",
"test_gemini_connection",
"update_system_role_config",
"list_webhooks",
"get_webhook",
"create_webhook",
"update_webhook",
"delete_webhook",
"test_webhook",
"create_org_unit",
"update_org_unit",
"create_country",
"update_country",
"create_metro_city",
@@ -220,6 +367,96 @@ export function getAvailableAssistantTools(permissions: Set<PermissionKey>, user
});
}
function normalizeAssistantText(input: string): string {
return input
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, " ")
.replace(/[^a-z0-9_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function tokenizeAssistantIntent(input: string): string[] {
return normalizeAssistantText(input)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !TOOL_SELECTION_STOP_WORDS.has(token));
}
export function selectAssistantToolsForRequest(
availableTools: typeof TOOL_DEFINITIONS,
messages: ChatMessage[],
pageContext?: string,
) {
if (availableTools.length <= MAX_OPENAI_TOOL_DEFINITIONS) {
return availableTools;
}
const recentUserText = messages
.filter((message) => message.role === "user")
.slice(-4)
.map((message) => message.content)
.join(" ");
const intentText = [recentUserText, pageContext ?? ""].filter(Boolean).join(" ");
const normalizedIntent = normalizeAssistantText(intentText);
const intentTokens = tokenizeAssistantIntent(intentText);
const mutationIntent = MUTATION_INTENT_KEYWORDS.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
const selectedHintTools = new Set<string>();
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
for (const toolName of hint.exactTools) {
selectedHintTools.add(toolName);
}
}
const scoredTools = availableTools
.map((tool, index) => {
const name = tool.function.name;
const normalizedName = normalizeAssistantText(name.replace(/_/g, " "));
const normalizedDescription = normalizeAssistantText(tool.function.description);
let score = 0;
if (ALWAYS_INCLUDED_TOOL_NAMES.has(name)) score += 1000;
if (selectedHintTools.has(name)) score += 400;
for (const hint of TOOL_SELECTION_HINTS) {
const matchedKeyword = hint.keywords.some((keyword) => normalizedIntent.includes(normalizeAssistantText(keyword)));
if (!matchedKeyword) continue;
if (hint.exactTools.includes(name)) score += 160;
if (hint.nameFragments.some((fragment) => name.includes(fragment))) score += 120;
if (hint.nameFragments.some((fragment) => normalizedDescription.includes(normalizeAssistantText(fragment)))) score += 40;
}
for (const token of intentTokens) {
if (normalizedName.includes(token)) score += 45;
if (normalizedDescription.includes(token)) score += 10;
}
if (name.startsWith("search_")) score += 18;
if (name.startsWith("get_")) score += 12;
if (name.startsWith("list_")) score += 10;
if (MUTATION_TOOLS.has(name)) {
score += mutationIntent ? 40 : -30;
} else {
score += 8;
}
return { tool, index, score };
})
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.index - right.index;
});
return scoredTools
.slice(0, MAX_OPENAI_TOOL_DEFINITIONS)
.map((entry) => entry.tool);
}
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
if (duplicateIndex >= 0) {
@@ -307,31 +544,87 @@ function toApprovalPayload(
};
}
function isAssistantApprovalTableMissingError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") return false;
const table = typeof error.meta?.table === "string" ? error.meta.table : "";
return table.includes("assistant_approvals") || error.message.includes("assistant_approvals");
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: {
table?: unknown;
};
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") return false;
const message = typeof candidate.message === "string"
? candidate.message
: "";
const metaTable = typeof candidate.meta?.table === "string"
? candidate.meta.table
: "";
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
}
function logAssistantApprovalStorageUnavailable(error: unknown) {
logger.warn(
{
err: error,
table: ASSISTANT_APPROVALS_TABLE_NAME,
},
"Assistant approval storage is unavailable",
);
}
async function withAssistantApprovalFallback<T>(
operation: () => Promise<T>,
fallback: () => T,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
return fallback();
}
}
export async function listPendingAssistantApprovals(
db: AssistantApprovalStore,
userId: string,
): Promise<PendingAssistantApproval[]> {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
const approvals = await db.assistantApproval.findMany({
where: {
userId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
return approvals.map(mapPendingApproval);
return approvals.map(mapPendingApproval);
}, () => []);
}
export async function clearPendingAssistantApproval(
@@ -339,17 +632,19 @@ export async function clearPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<void> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
await withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
data: {
status: AssistantApprovalStatus.CANCELLED,
cancelledAt: new Date(),
},
});
}, () => undefined);
}
export async function peekPendingAssistantApproval(
@@ -357,28 +652,30 @@ export async function peekPendingAssistantApproval(
userId: string,
conversationId: string,
): Promise<PendingAssistantApproval | null> {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
return withAssistantApprovalFallback(async () => {
await db.assistantApproval.updateMany({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
expiresAt: { lte: new Date() },
},
data: {
status: AssistantApprovalStatus.EXPIRED,
},
});
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
const pending = await db.assistantApproval.findFirst({
where: {
userId,
conversationId,
status: AssistantApprovalStatus.PENDING,
},
orderBy: { createdAt: "desc" },
});
if (!pending) return null;
return mapPendingApproval(pending);
}, () => null);
}
export async function consumePendingAssistantApproval(
@@ -426,19 +723,25 @@ export async function createPendingAssistantApproval(
const now = new Date();
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
try {
await clearPendingAssistantApproval(db, userId, conversationId);
const pendingApproval = await db.assistantApproval.create({
data: {
userId,
conversationId,
toolName,
toolArguments,
summary,
createdAt: now,
expiresAt,
},
});
return mapPendingApproval(pendingApproval);
} catch (error) {
if (!isAssistantApprovalTableMissingError(error)) throw error;
logAssistantApprovalStorageUnavailable(error);
throw new AssistantApprovalStorageUnavailableError();
}
}
function isAffirmativeConfirmationReply(content: string): boolean {
@@ -669,7 +972,11 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
const availableTools = getAvailableAssistantTools(permissions, userRole);
const availableTools = selectAssistantToolsForRequest(
getAvailableAssistantTools(permissions, userRole),
input.messages,
input.pageContext,
);
// 5. Function calling loop
const toolCtx: ToolContext = {
@@ -799,13 +1106,26 @@ export const assistantRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const toolCall of msg.tool_calls as Array<{ id: string; function: { name: string; arguments: string } }>) {
if (MUTATION_TOOLS.has(toolCall.function.name)) {
const approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
let approval: PendingAssistantApproval;
try {
approval = await createPendingAssistantApproval(
ctx.db,
userId,
conversationId,
toolCall.function.name,
toolCall.function.arguments,
);
} catch (error) {
if (!(error instanceof AssistantApprovalStorageUnavailableError)) {
throw error;
}
return {
content: "Schreibende Assistant-Aktionen sind gerade nicht verfuegbar, weil der Bestaetigungsspeicher in der Datenbank fehlt. Bitte die CapaKraken-DB-Migration anwenden und dann erneut versuchen.",
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
void createAuditEntry({
db: ctx.db,
+328 -99
View File
@@ -1,6 +1,235 @@
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
type AuditUser = { id: string; name: string | null; email: string | null } | null | undefined;
type AuditEntryShape = {
id: string;
entityType: string;
entityId: string;
entityName?: string | null;
action: string;
userId?: string | null;
source?: string | null;
summary?: string | null;
createdAt: Date;
user?: AuditUser;
};
type AuditDetailEntryShape = AuditEntryShape & {
changes?: unknown;
};
function formatAuditListEntry(entry: AuditEntryShape) {
return {
id: entry.id,
entityType: entry.entityType,
entityId: entry.entityId,
entityName: entry.entityName ?? null,
action: entry.action,
userId: entry.userId ?? null,
source: entry.source ?? null,
summary: entry.summary ?? null,
createdAt: entry.createdAt.toISOString(),
user: entry.user
? {
id: entry.user.id,
name: entry.user.name,
email: entry.user.email,
}
: null,
};
}
function formatAuditDetailEntry(entry: AuditDetailEntryShape) {
return {
...formatAuditListEntry(entry),
changes: entry.changes ?? null,
};
}
type AuditListInput = {
entityType?: string;
entityId?: string;
userId?: string;
action?: string;
source?: string;
startDate?: Date;
endDate?: Date;
search?: string;
limit: number;
cursor?: string;
};
type AuditTimelineInput = {
startDate?: Date;
endDate?: Date;
limit: number;
};
function toAuditListInput(input: {
entityType?: string | undefined;
entityId?: string | undefined;
userId?: string | undefined;
action?: string | undefined;
source?: string | undefined;
startDate?: Date | undefined;
endDate?: Date | undefined;
search?: string | undefined;
limit: number;
cursor?: string | undefined;
}): AuditListInput {
return {
limit: input.limit,
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.userId !== undefined ? { userId: input.userId } : {}),
...(input.action !== undefined ? { action: input.action } : {}),
...(input.source !== undefined ? { source: input.source } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.search !== undefined ? { search: input.search } : {}),
...(input.cursor !== undefined ? { cursor: input.cursor } : {}),
};
}
function toAuditTimelineInput(input: {
startDate?: Date | undefined;
endDate?: Date | undefined;
limit: number;
}): AuditTimelineInput {
return {
limit: input.limit,
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
};
}
function buildAuditListWhere(input: Omit<AuditListInput, "limit" | "cursor">) {
const { entityType, entityId, userId, action, source, startDate, endDate, search } = input;
const where: Record<string, unknown> = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (userId) where.userId = userId;
if (action) where.action = action;
if (source) where.source = source;
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
where.createdAt = createdAt;
}
if (search) {
where.OR = [
{ entityName: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
];
}
if (!startDate && !endDate && !entityId) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
}
return where;
}
async function listAuditEntries(
db: { auditLog: { findMany: Function } },
input: AuditListInput,
) {
const items = await db.auditLog.findMany({
where: buildAuditListWhere(input),
select: {
id: true,
entityType: true,
entityId: true,
entityName: true,
action: true,
userId: true,
source: true,
summary: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > input.limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
}
async function getAuditEntryById(
db: { auditLog: { findUniqueOrThrow: Function } },
id: string,
) {
return db.auditLog.findUniqueOrThrow({
where: { id },
include: { user: { select: { id: true, name: true, email: true } } },
});
}
async function getAuditEntriesByEntity(
db: { auditLog: { findMany: Function } },
input: { entityType: string; entityId: string; limit: number },
) {
return db.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
}
async function getAuditTimeline(
db: { auditLog: { findMany: Function } },
input: AuditTimelineInput,
) {
const where: Record<string, unknown> = {};
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
const entries = await db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
const grouped: Record<string, typeof entries> = {};
for (const entry of entries) {
const dateKey = entry.createdAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) grouped[dateKey] = [];
grouped[dateKey].push(entry);
}
return grouped;
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const auditLogRouter = createTRPCRouter({
@@ -24,65 +253,52 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = input;
return listAuditEntries(ctx.db, toAuditListInput({
entityType: input.entityType,
entityId: input.entityId,
userId: input.userId,
action: input.action,
source: input.source,
startDate: input.startDate,
endDate: input.endDate,
search: input.search,
limit: input.limit,
cursor: input.cursor,
}));
}),
const where: Record<string, unknown> = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (userId) where.userId = userId;
if (action) where.action = action;
if (source) where.source = source;
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = startDate;
if (endDate) createdAt.lte = endDate;
where.createdAt = createdAt;
}
if (search) {
where.OR = [
{ entityName: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
];
}
// Default to last 30 days if no date filter to avoid full table scan
if (!startDate && !endDate && !entityId) {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
where.createdAt = { ...(where.createdAt as Record<string, Date> ?? {}), gte: thirtyDaysAgo };
}
const items = await ctx.db.auditLog.findMany({
where,
select: {
id: true,
entityType: true,
entityId: true,
entityName: true,
action: true,
userId: true,
source: true,
summary: true,
createdAt: true,
user: { select: { id: true, name: true, email: true } },
// Exclude 'changes' from list query — fetch on demand when expanding
},
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
let nextCursor: string | undefined;
if (items.length > limit) {
const next = items.pop();
nextCursor = next?.id;
}
return { items, nextCursor };
listDetail: controllerProcedure
.input(
z.object({
entityType: z.string().optional(),
entityId: z.string().optional(),
userId: z.string().optional(),
action: z.string().optional(),
source: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
search: z.string().optional(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const result = await listAuditEntries(ctx.db, toAuditListInput({
entityType: input.entityType,
entityId: input.entityId,
userId: input.userId,
action: input.action,
source: input.source,
startDate: input.startDate,
endDate: input.endDate,
search: input.search,
limit: input.limit,
cursor: input.cursor,
}));
return {
items: result.items.map(formatAuditListEntry),
nextCursor: result.nextCursor ?? null,
};
}),
/**
@@ -91,10 +307,14 @@ export const auditLogRouter = createTRPCRouter({
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findUniqueOrThrow({
where: { id: input.id },
include: { user: { select: { id: true, name: true, email: true } } },
});
return getAuditEntryById(ctx.db, input.id);
}),
getByIdDetail: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const entry = await getAuditEntryById(ctx.db, input.id);
return formatAuditDetailEntry(entry);
}),
/**
@@ -109,17 +329,26 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
return getAuditEntriesByEntity(ctx.db, input);
}),
getByEntityDetail: controllerProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
limit: z.number().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) => {
const entries = await getAuditEntriesByEntity(ctx.db, input);
return {
entityType: input.entityType,
entityId: input.entityId,
entityName: entries[0]?.entityName ?? null,
itemCount: entries.length,
items: entries.map(formatAuditDetailEntry),
};
}),
/**
@@ -134,33 +363,33 @@ export const auditLogRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {};
return getAuditTimeline(ctx.db, toAuditTimelineInput({
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
}));
}),
if (input.startDate || input.endDate) {
const createdAt: Record<string, Date> = {};
if (input.startDate) createdAt.gte = input.startDate;
if (input.endDate) createdAt.lte = input.endDate;
where.createdAt = createdAt;
}
const entries = await ctx.db.auditLog.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
// Group by date string (YYYY-MM-DD)
const grouped: Record<string, typeof entries> = {};
for (const entry of entries) {
const dateKey = entry.createdAt.toISOString().slice(0, 10);
if (!grouped[dateKey]) grouped[dateKey] = [];
grouped[dateKey].push(entry);
}
return grouped;
getTimelineDetail: controllerProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
limit: z.number().min(1).max(500).default(200),
}),
)
.query(async ({ ctx, input }) => {
const timeline = await getAuditTimeline(ctx.db, toAuditTimelineInput({
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
}));
return Object.fromEntries(
Object.entries(timeline).map(([dateKey, entries]) => [
dateKey,
entries.map(formatAuditDetailEntry),
]),
);
}),
/**
+76
View File
@@ -6,6 +6,18 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js
import { createAuditEntry } from "../lib/audit.js";
export const blueprintRouter = createTRPCRouter({
listSummaries: protectedProcedure
.query(async ({ ctx }) => {
return ctx.db.blueprint.findMany({
select: {
id: true,
name: true,
_count: { select: { projects: true } },
},
orderBy: { name: "asc" },
});
}),
list: protectedProcedure
.input(
z.object({
@@ -33,6 +45,70 @@ export const blueprintRouter = createTRPCRouter({
return blueprint;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
target: true,
isActive: true,
} as const;
let blueprint = await ctx.db.blueprint.findUnique({
where: { id: identifier },
select,
});
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
}
return blueprint;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let blueprint = await ctx.db.blueprint.findUnique({
where: { id: identifier },
});
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
});
}
if (!blueprint) {
blueprint = await ctx.db.blueprint.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
});
}
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` });
}
return blueprint;
}),
create: adminProcedure
.input(CreateBlueprintSchema)
.mutation(async ({ ctx, input }) => {
+284 -204
View File
@@ -7,10 +7,12 @@ import {
getMonthKeys,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { PermissionKey } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
@@ -18,221 +20,299 @@ import {
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(
z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth, includeProposed } = input;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
const reportInputSchema = z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
});
// Fetch resources with filters
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const detailedReportInputSchema = reportInputSchema.extend({
resourceQuery: z.string().optional(),
resourceLimit: z.number().int().min(1).max(100).optional(),
});
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
type ChargeabilityReportDbClient = Pick<
PrismaClient,
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
>;
async function queryChargeabilityReport(
db: ChargeabilityReportDbClient,
input: z.infer<typeof reportInputSchema>,
) {
const { startMonth, endMonth, includeProposed } = input;
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const resources = await db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
const resourceIds = resources.map((resource) => resource.id);
const allBookings = await listAssignmentBookings(db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
const projectUtilCats = projectIds.length > 0
? await db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
);
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((booking) => ({
resourceId: booking.resourceId!,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
project: {
status: booking.project.status,
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
},
}));
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [year, month] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
// Fetch all bookings (assignments + legacy allocations) in the date range
const resourceIds = resources.map((r) => r.id);
const allBookings = await listAssignmentBookings(ctx.db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
const projectUtilCats = projectIds.length > 0
? await ctx.db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
);
// Normalize bookings to a common shape
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
project: {
status: b.project.status,
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
},
}));
// Build per-resource, per-month forecasts
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
return {
hoursPerDay: a.hoursPerDay,
workingDays: 0,
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
totalChargeableHours,
};
});
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
return {
monthKey: key,
sah: availableHours,
...forecast,
};
}));
const categoryCode = assignment.project.utilizationCategory?.code;
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((r) => ({
fte: r.fte,
chargeability: r.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((r) => ({
fte: r.fte,
targetPercentage: r.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
hoursPerDay: assignment.hoursPerDay,
workingDays: 0,
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
totalChargeableHours,
};
});
const directory = await getAnonymizationDirectory(ctx.db);
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
monthKey: key,
sah: availableHours,
...forecast,
};
}));
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((resource) => ({
fte: resource.fte,
chargeability: resource.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((resource) => ({
fte: resource.fte,
targetPercentage: resource.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
const directory = await getAnonymizationDirectory(db);
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}
function buildChargeabilityReportDetail(
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
input: z.infer<typeof detailedReportInputSchema>,
) {
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
const matchingResources = resourceQuery
? report.resources.filter((resource) => (
resource.displayName.toLowerCase().includes(resourceQuery)
|| resource.eid.toLowerCase().includes(resourceQuery)
))
: report.resources;
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: round1(resource.fte),
country: resource.country,
city: resource.city,
orgUnit: resource.orgUnit,
managementLevelGroup: resource.mgmtGroup,
managementLevel: resource.mgmtLevel,
targetPct: round1(resource.targetPct * 100),
months: resource.months.map((month) => ({
monthKey: month.monthKey,
sah: round1(month.sah),
chargeabilityPct: round1(month.chg * 100),
targetPct: round1(resource.targetPct * 100),
gapPct: round1((month.chg - resource.targetPct) * 100),
})),
}));
return {
filters: {
startMonth: input.startMonth,
endMonth: input.endMonth,
orgUnitId: input.orgUnitId ?? null,
managementLevelGroupId: input.managementLevelGroupId ?? null,
countryId: input.countryId ?? null,
includeProposed: input.includeProposed ?? false,
resourceQuery: input.resourceQuery ?? null,
},
monthKeys: report.monthKeys,
groupTotals: report.groupTotals.map((group) => ({
monthKey: group.monthKey,
totalFte: round1(group.totalFte),
chargeabilityPct: round1(group.chg * 100),
targetPct: round1(group.target * 100),
gapPct: round1(group.gap * 100),
})),
resourceCount: matchingResources.length,
returnedResourceCount: resources.length,
truncated: resources.length < matchingResources.length,
resources,
};
}
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(reportInputSchema)
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
getDetail: controllerProcedure
.input(detailedReportInputSchema)
.query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const report = await queryChargeabilityReport(ctx.db, input);
return buildChargeabilityReportDetail(report, input);
}),
});
+98 -1
View File
@@ -44,7 +44,12 @@ export const clientRouter = createTRPCRouter({
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" as const } },
{ code: { contains: input.search, mode: "insensitive" as const } },
],
}
: {}),
},
include: { _count: { select: { children: true, projects: true } } },
@@ -81,6 +86,98 @@ export const clientRouter = createTRPCRouter({
return client;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
code: true,
parentId: true,
isActive: true,
} as const;
let client = await ctx.db.client.findUnique({
where: { id: identifier },
select,
});
if (!client) {
client = await ctx.db.client.findUnique({
where: { code: identifier },
select,
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ code: { contains: identifier, mode: "insensitive" } },
],
},
select,
});
}
if (!client) {
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
}
return client;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let client = await ctx.db.client.findUnique({
where: { id: identifier },
include: { _count: { select: { projects: true, children: true } } },
});
if (!client) {
client = await ctx.db.client.findUnique({
where: { code: identifier },
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
client = await ctx.db.client.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ code: { contains: identifier, mode: "insensitive" } },
],
},
include: { _count: { select: { projects: true, children: true } } },
});
}
if (!client) {
throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` });
}
return client;
}),
create: managerProcedure
.input(CreateClientSchema)
.mutation(async ({ ctx, input }) => {
File diff suppressed because it is too large Load Diff
+108 -1
View File
@@ -30,6 +30,100 @@ export const countryRouter = createTRPCRouter({
});
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
code: true,
name: true,
isActive: true,
dailyWorkingHours: true,
} as const;
let country = await ctx.db.country.findUnique({
where: { id: identifier },
select,
});
if (!country) {
country = await ctx.db.country.findFirst({
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
select,
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!country) {
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
}
return country;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let country = await ctx.db.country.findUnique({
where: { id: identifier },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!country) {
country = await ctx.db.country.findFirst({
where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
country = await ctx.db.country.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
}
if (!country) {
throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` });
}
return country;
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
@@ -46,6 +140,19 @@ export const countryRouter = createTRPCRouter({
return country;
}),
getCityById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const city = await findUniqueOrThrow(
ctx.db.metroCity.findUnique({
where: { id: input.id },
select: { id: true, name: true, countryId: true },
}),
"Metro city",
);
return city;
}),
create: adminProcedure
.input(CreateCountrySchema)
.mutation(async ({ ctx, input }) => {
@@ -207,6 +314,6 @@ export const countryRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: city.id, name: city.name };
}),
});
+287 -50
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
@@ -8,25 +8,182 @@ import {
getDashboardTopValueResources,
getDashboardBudgetForecast,
getDashboardSkillGaps,
getDashboardSkillGapSummary,
getDashboardProjectHealth,
} from "@capakraken/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js";
import { fmtEur } from "../lib/format-utils.js";
const DEFAULT_TTL = 60; // seconds
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
const result = await getDashboardOverview(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
const projects = rows
.map((project) => {
const overall = project.compositeScore;
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
return {
activeResources: overview.activeResources,
totalProjects: overview.totalProjects,
activeProjects: overview.activeProjects,
totalAllocations: overview.totalAllocations,
approvedVacations: overview.approvedVacations,
totalEstimates: overview.totalEstimates,
totalBudget: overview.budgetSummary.totalBudgetCents > 0
? fmtEur(overview.budgetSummary.totalBudgetCents)
: "N/A",
projectsByStatus: Object.fromEntries(
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
),
topChapters: [...overview.chapterUtilization]
.sort((left, right) => right.resourceCount - left.resourceCount)
.slice(0, 10)
.map((chapter) => ({
chapter: chapter.chapter,
count: chapter.resourceCount,
})),
};
}
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
const cacheKey = "overview";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardOverview(db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getPeakTimesCached(
db: Parameters<typeof getDashboardPeakTimes>[0],
input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" },
) {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getDemandCached(
db: Parameters<typeof getDashboardDemand>[0],
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
) {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardDemand(db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
async function getTopValueResourcesCached(
db: Parameters<typeof getDashboardTopValueResources>[0],
input: { limit: number; userRole: string },
) {
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(db, {
limit: input.limit,
userRole: input.userRole,
}),
getAnonymizationDirectory(db),
]);
const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}
export const dashboardRouter = createTRPCRouter({
getOverview: controllerProcedure.query(async ({ ctx }) => {
return getOverviewCached(ctx.db);
}),
getPeakTimes: protectedProcedure
getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => {
const overview = await getOverviewCached(ctx.db);
return mapStatisticsDetail(overview);
}),
getPeakTimes: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
@@ -36,42 +193,18 @@ export const dashboardRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardPeakTimes(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
return getPeakTimesCached(ctx.db, input);
}),
getTopValueResources: protectedProcedure
getTopValueResources: controllerProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
const cacheKey = `topValue:${input.limit}:${userRole}`;
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
if (cached) return cached;
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole,
}),
getAnonymizationDirectory(ctx.db),
]);
const result = anonymizeResources(resources, directory);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole });
}),
getDemand: protectedProcedure
getDemand: controllerProcedure
.input(
z.object({
startDate: z.string().datetime(),
@@ -80,16 +213,100 @@ export const dashboardRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
if (cached) return cached;
return getDemandCached(ctx.db, input);
}),
getDetail: controllerProcedure
.input(z.object({ section: z.string().optional().default("all") }))
.query(async ({ ctx, input }) => {
const section = input.section;
const result: Record<string, unknown> = {};
const needsOverview =
section === "all"
|| section === "peak_times"
|| section === "demand_pipeline"
|| section === "chargeability_overview";
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
const now = new Date();
const rangeStart = overview?.budgetBasis.windowStart
? new Date(overview.budgetBasis.windowStart)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const rangeEnd = overview?.budgetBasis.windowEnd
? new Date(overview.budgetBasis.windowEnd)
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
const userRole =
(ctx.session.user as { role?: string } | undefined)?.role
?? ctx.dbUser?.systemRole
?? "USER";
if (section === "all" || section === "peak_times") {
const peakTimes = await getPeakTimesCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
granularity: "month",
groupBy: "project",
});
result.peakTimes = [...peakTimes]
.sort((left, right) => right.totalHours - left.totalHours)
.slice(0, 6)
.map((entry) => ({
month: entry.period,
totalHours: round1(entry.totalHours),
totalHoursPerDay: round1(entry.totalHours),
capacityHours: round1(entry.capacityHours),
utilizationPct: entry.utilizationPct ?? null,
}));
}
if (section === "all" || section === "top_resources") {
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
result.topResources = resources.map((resource) => {
const topResource = resource as {
displayName: string;
eid: string;
chapter: string | null;
lcrCents: number;
valueScore: number | null;
};
return {
name: topResource.displayName,
eid: topResource.eid,
chapter: topResource.chapter ?? null,
lcr: fmtEur(topResource.lcrCents),
valueScore: topResource.valueScore ?? null,
};
});
}
if (section === "all" || section === "demand_pipeline") {
const demandRows = await getDemandCached(ctx.db, {
startDate: rangeStart.toISOString(),
endDate: rangeEnd.toISOString(),
groupBy: "project",
});
result.demandPipeline = demandRows
.map((row) => ({
project: `${row.name} (${row.shortCode})`,
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
requiredFTEs: row.requiredFTEs,
allocatedResources: row.resourceCount,
allocatedHours: row.allocatedHours,
calendarLocations: row.derivation?.calendarLocations ?? [],
}))
.filter((row) => row.needed > 0)
.sort((left, right) => right.needed - left.needed)
.slice(0, 15);
}
if (section === "all" || section === "chargeability_overview") {
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
chapter: chapter.chapter ?? "Unassigned",
headcount: chapter.resourceCount,
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
}));
}
const result = await getDashboardDemand(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
});
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
@@ -133,7 +350,7 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getBudgetForecast: protectedProcedure.query(async ({ ctx }) => {
getBudgetForecast: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "budgetForecast";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
if (cached) return cached;
@@ -143,7 +360,12 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getSkillGaps: protectedProcedure.query(async ({ ctx }) => {
getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => {
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
return mapBudgetForecastDetailRows(budgetForecast);
}),
getSkillGaps: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGaps";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
if (cached) return cached;
@@ -153,7 +375,17 @@ export const dashboardRouter = createTRPCRouter({
return result;
}),
getProjectHealth: protectedProcedure.query(async ({ ctx }) => {
getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGapSummary";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGapSummary(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealth: controllerProcedure.query(async ({ ctx }) => {
const cacheKey = "projectHealth";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
if (cached) return cached;
@@ -162,4 +394,9 @@ export const dashboardRouter = createTRPCRouter({
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => {
const projectHealth = await getDashboardProjectHealth(ctx.db);
return mapProjectHealthDetailRows(projectHealth);
}),
});
+199 -86
View File
@@ -23,6 +23,167 @@ type EntitlementSnapshot = {
pendingDays: number;
};
function mapBalanceDetail(resource: {
displayName: string;
eid: string;
}, balance: {
year: number;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
remainingDays: number;
sickDays: number;
}) {
return {
resource: resource.displayName,
eid: resource.eid,
year: balance.year,
entitlement: balance.entitledDays,
carryOver: balance.carryoverDays,
taken: balance.usedDays,
pending: balance.pendingDays,
remaining: balance.remainingDays,
sickDays: balance.sickDays,
};
}
function mapYearSummaryDetail(
year: number,
summaries: Array<{
displayName: string;
eid: string;
chapter: string | null;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
remainingDays: number;
}>,
resourceName?: string,
) {
const needle = resourceName?.toLowerCase();
return summaries
.filter((summary) => {
if (!needle) {
return true;
}
return summary.displayName.toLowerCase().includes(needle)
|| summary.eid.toLowerCase().includes(needle);
})
.slice(0, 50)
.map((summary) => ({
resource: summary.displayName,
eid: summary.eid,
chapter: summary.chapter ?? null,
year,
entitled: summary.entitledDays,
carryover: summary.carryoverDays,
used: summary.usedDays,
pending: summary.pendingDays,
remaining: summary.remainingDays,
}));
}
type EntitlementReadContext = Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"];
async function readBalanceSnapshot(
ctx: Pick<EntitlementReadContext, "db" | "dbUser">,
input: { resourceId: string; year: number },
) {
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
}
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, vacation) => sum + countCalendarDaysInPeriod(
vacation,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}
async function readYearSummarySnapshot(
ctx: Pick<EntitlementReadContext, "db">,
input: { year: number; chapter?: string },
) {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
return Promise.all(
resources.map(async (resource) => {
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
return {
resourceId: resource.id,
displayName: resource.displayName,
eid: resource.eid,
chapter: resource.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
@@ -163,6 +324,15 @@ export const entitlementRouter = createTRPCRouter({
* Creates the entitlement record if it doesn't exist (with carryover).
*/
getBalance: protectedProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
}),
)
.query(async ({ ctx, input }) => readBalanceSnapshot(ctx, input)),
getBalanceDetail: protectedProcedure
.input(
z.object({
resourceId: z.string(),
@@ -170,63 +340,20 @@ export const entitlementRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
// Ownership check: USER can only query their own balance
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
const balance = await readBalanceSnapshot(ctx, input);
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
// Sync from real vacation records
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, v) => sum + countCalendarDaysInPeriod(
v,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
return mapBalanceDetail(resource, balance);
}),
/**
@@ -366,39 +493,25 @@ export const entitlementRouter = createTRPCRouter({
chapter: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
.query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
})),
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
getYearSummaryDetail: managerProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
resourceName: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const summaries = await readYearSummarySnapshot(ctx, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
});
const results = await Promise.all(
resources.map(async (r) => {
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
return {
resourceId: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
return results;
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
}),
});
+314 -109
View File
@@ -47,6 +47,38 @@ import {
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";
type EstimateRouterErrorRule = {
code: EstimateRouterErrorCode;
messages?: readonly string[];
predicates?: readonly ((message: string) => boolean)[];
};
function rethrowEstimateRouterError(
error: unknown,
rules: readonly EstimateRouterErrorRule[],
): never {
if (!(error instanceof Error)) {
throw error;
}
const matchingRule = rules.find(
(rule) =>
rule.messages?.includes(error.message) === true ||
rule.predicates?.some((predicate) => predicate(error.message)) === true,
);
if (matchingRule) {
throw new TRPCError({
code: matchingRule.code,
message: error.message,
});
}
throw error;
}
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
@@ -235,6 +267,199 @@ export const estimateRouter = createTRPCRouter({
return estimate;
}),
listVersions: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
latestVersionNumber: true,
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
assumptions: true,
scopeItems: true,
demandLines: true,
resourceSnapshots: true,
exports: true,
},
},
},
},
},
}),
"Estimate",
);
return estimate;
}),
getVersionSnapshot: controllerProcedure
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const estimate = await ctx.db.estimate.findUnique({
where: { id: input.estimateId },
select: {
id: true,
name: true,
status: true,
baseCurrency: true,
versions: {
...(input.versionId
? { where: { id: input.versionId } }
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
select: {
id: true,
versionNumber: true,
label: true,
status: true,
notes: true,
lockedAt: true,
createdAt: true,
updatedAt: true,
assumptions: {
select: { id: true, category: true, key: true, label: true },
},
scopeItems: {
select: { id: true, scopeType: true, sequenceNo: true, name: true },
orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
},
demandLines: {
select: {
id: true,
name: true,
chapter: true,
hours: true,
costTotalCents: true,
priceTotalCents: true,
currency: true,
},
},
resourceSnapshots: {
select: {
id: true,
displayName: true,
chapter: true,
currency: true,
lcrCents: true,
ucrCents: true,
},
},
exports: {
select: {
id: true,
format: true,
fileName: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!estimate || estimate.versions.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
}
const version = estimate.versions[0]!;
const demandSummary = summarizeEstimateDemandLines(version.demandLines);
const chapterTotals = version.demandLines.reduce<Record<string, {
lineCount: number;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
}>>((acc, line) => {
const key = line.chapter ?? "Unassigned";
const current = acc[key] ?? {
lineCount: 0,
hours: 0,
costTotalCents: 0,
priceTotalCents: 0,
currency: line.currency,
};
current.lineCount += 1;
current.hours += line.hours;
current.costTotalCents += line.costTotalCents;
current.priceTotalCents += line.priceTotalCents;
acc[key] = current;
return acc;
}, {});
const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
return acc;
}, {});
const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
return acc;
}, {});
return {
estimate: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
baseCurrency: estimate.baseCurrency,
},
version: {
id: version.id,
versionNumber: version.versionNumber,
label: version.label,
status: version.status,
notes: version.notes,
lockedAt: version.lockedAt,
createdAt: version.createdAt,
updatedAt: version.updatedAt,
},
counts: {
assumptions: version.assumptions.length,
scopeItems: version.scopeItems.length,
demandLines: version.demandLines.length,
resourceSnapshots: version.resourceSnapshots.length,
exports: version.exports.length,
},
totals: {
hours: demandSummary.totalHours,
costTotalCents: demandSummary.totalCostCents,
priceTotalCents: demandSummary.totalPriceCents,
marginCents: demandSummary.marginCents,
marginPercent: demandSummary.marginPercent,
},
chapterBreakdown: Object.entries(chapterTotals)
.sort((left, right) => right[1].hours - left[1].hours)
.map(([chapter, totals]) => ({
chapter,
...totals,
})),
scopeTypeBreakdown: Object.entries(scopeTypeTotals)
.sort((left, right) => right[1] - left[1])
.map(([scopeType, count]) => ({ scopeType, count })),
assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
.sort((left, right) => right[1] - left[1])
.map(([category, count]) => ({ category, count })),
exports: version.exports,
};
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
@@ -294,15 +519,12 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Source estimate not found" ||
error.message === "Source estimate has no versions"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Source estimate not found", "Source estimate has no versions"],
},
]);
}
await ctx.db.auditLog.create({
@@ -360,19 +582,16 @@ export const estimateRouter = createTRPCRouter({
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error instanceof Error &&
error.message === "Estimate has no working version"
) {
throw new TRPCError({
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found"],
},
{
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
messages: ["Estimate has no working version"],
},
]);
}
await ctx.db.auditLog.create({
@@ -411,24 +630,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no working version" ||
error.message === "Only working versions can be submitted"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no working version",
"Only working versions can be submitted",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -464,24 +678,19 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no submitted version" ||
error.message === "Only submitted versions can be approved"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no submitted version",
"Only submitted versions can be approved",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -517,25 +726,20 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate already has a working version" ||
error.message === "Estimate has no locked version to revise" ||
error.message === "Source version must be locked before creating a revision"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: ["Estimate not found", "Estimate version not found"],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate already has a working version",
"Estimate has no locked version to revise",
"Source version must be locked before creating a revision",
],
},
]);
}
await ctx.db.auditLog.create({
@@ -572,16 +776,16 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Estimate has no version to export"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Estimate has no version to export",
],
},
]);
}
const exportedVersion = input.versionId
@@ -620,29 +824,30 @@ export const estimateRouter = createTRPCRouter({
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Linked project not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no approved version" ||
error.message === "Only approved versions can be handed off to planning" ||
error.message === "Estimate must be linked to a project before planning handoff" ||
error.message === "Planning handoff already exists for this approved version" ||
error.message === "Linked project has an invalid date range" ||
error.message.startsWith("Project window has no working days for demand line")
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
rethrowEstimateRouterError(error, [
{
code: "NOT_FOUND",
messages: [
"Estimate not found",
"Estimate version not found",
"Linked project not found",
],
},
{
code: "PRECONDITION_FAILED",
messages: [
"Estimate has no approved version",
"Only approved versions can be handed off to planning",
"Estimate must be linked to a project before planning handoff",
"Planning handoff already exists for this approved version",
"Linked project has an invalid date range",
],
predicates: [
(message) =>
message.startsWith("Project window has no working days for demand line"),
],
},
]);
}
await ctx.db.auditLog.create({
+478 -49
View File
@@ -14,6 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
type HolidayCalendarScope = HolidayCalendarScopeInput;
type HolidayReadContext = Pick<TRPCContext, "db">;
const HOLIDAY_SCOPE = {
COUNTRY: "COUNTRY",
@@ -49,6 +50,401 @@ function clampDate(date: Date): Date {
return value;
}
function fmtDate(value: Date | null | undefined): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function formatIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function formatHolidayCalendarEntryDetail(entry: {
id: string;
date: Date;
name: string;
isRecurringAnnual?: boolean | null;
source?: string | null;
}) {
return {
id: entry.id,
date: formatIsoDate(entry.date),
name: entry.name,
isRecurringAnnual: entry.isRecurringAnnual ?? false,
source: entry.source ?? null,
};
}
function formatHolidayCalendarDetail(calendar: {
id: string;
name: string;
scopeType: string;
stateCode?: string | null;
isActive?: boolean | null;
priority?: number | null;
country?: { id: string; code: string; name: string } | null;
metroCity?: { id: string; name: string } | null;
_count?: { entries?: number | null } | null;
entries?: Array<{
id: string;
date: Date;
name: string;
isRecurringAnnual?: boolean | null;
source?: string | null;
}> | null;
}) {
const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? [];
return {
id: calendar.id,
name: calendar.name,
scopeType: calendar.scopeType,
stateCode: calendar.stateCode ?? null,
isActive: calendar.isActive ?? true,
priority: calendar.priority ?? 0,
country: calendar.country
? {
id: calendar.country.id,
code: calendar.country.code,
name: calendar.country.name,
}
: null,
metroCity: calendar.metroCity
? {
id: calendar.metroCity.id,
name: calendar.metroCity.name,
}
: null,
entryCount: calendar._count?.entries ?? entries.length,
entries,
};
}
function formatResolvedHolidayDetail(holiday: {
date: string;
name: string;
scopeType: string;
calendarName: string;
sourceType: string;
}) {
return {
date: holiday.date,
name: holiday.name,
scope: holiday.scopeType,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
};
}
function summarizeResolvedHolidaysDetail(holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>) {
const byScope = new Map<string, number>();
const bySourceType = new Map<string, number>();
const byCalendar = new Map<string, number>();
for (const holiday of holidays) {
byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
}
return {
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
bySourceType: [...bySourceType.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([sourceType, count]) => ({ sourceType, count })),
byCalendar: [...byCalendar.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([calendarName, count]) => ({ calendarName, count })),
};
}
const ResolveHolidaysInputSchema = z.object({
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
countryId: z.string().optional(),
countryCode: z.string().trim().min(1).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCityId: z.string().optional(),
metroCityName: z.string().trim().min(1).optional(),
}).superRefine((input, issueCtx) => {
if (!input.countryId && !input.countryCode) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "Either countryId or countryCode is required.",
path: ["countryId"],
});
}
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
const ResolveResourceHolidaysInputSchema = z.object({
resourceId: z.string(),
periodStart: z.coerce.date(),
periodEnd: z.coerce.date(),
}).superRefine((input, issueCtx) => {
if (input.periodEnd < input.periodStart) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
message: "periodEnd must be on or after periodStart.",
path: ["periodEnd"],
});
}
});
async function readCalendarsSnapshot(
ctx: HolidayReadContext,
input?: {
includeInactive?: boolean | undefined;
countryCode?: string | undefined;
scopeType?: "COUNTRY" | "STATE" | "CITY" | undefined;
stateCode?: string | undefined;
metroCity?: string | undefined;
},
) {
const db = asHolidayCalendarDb(ctx.db);
const where = {
...(input?.includeInactive ? {} : { isActive: true }),
...(input?.countryCode
? {
country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } },
}
: {}),
...(input?.scopeType ? { scopeType: input.scopeType } : {}),
...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
...(input?.metroCity
? {
metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } },
}
: {}),
};
return db.holidayCalendar.findMany({
where,
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
}
async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) {
const db = asHolidayCalendarDb(ctx.db);
const trimmedIdentifier = identifier.trim();
let calendar = await db.holidayCalendar.findUnique({
where: { id: trimmedIdentifier },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
if (!calendar) {
calendar = await db.holidayCalendar.findFirst({
where: { name: { equals: trimmedIdentifier, mode: "insensitive" } },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
}
if (!calendar) {
calendar = await db.holidayCalendar.findFirst({
where: { name: { contains: trimmedIdentifier, mode: "insensitive" } },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
}
if (!calendar) {
throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` });
}
return calendar;
}
async function readPreviewResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof PreviewResolvedHolidaysSchema>,
) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { id: true, code: true, name: true },
}),
"Country",
);
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { id: true, name: true, countryId: true },
})
: null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
return {
locationContext: {
countryId: input.countryId,
countryCode: country.code,
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCity?.name ?? null,
year: input.year,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveHolidaysInputSchema>,
) {
let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null;
if (!resolvedCountryCode && input.countryId) {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
resolvedCountryCode = country.code;
}
const metroCityName = input.metroCityId
? (await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
}))?.name ?? null
: input.metroCityName?.trim() ?? null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
locationContext: {
countryId: input.countryId ?? null,
countryCode: resolvedCountryCode,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCity: metroCityName,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function readResolvedResourceHolidaysSnapshot(
ctx: HolidayReadContext,
input: z.infer<typeof ResolveResourceHolidaysInputSchema>,
) {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
eid: true,
displayName: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
);
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.periodStart,
periodEnd: input.periodEnd,
countryId: resource.countryId ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCityId: resource.metroCityId ?? null,
metroCityName: resource.metroCity?.name ?? null,
});
return {
periodStart: input.periodStart.toISOString().slice(0, 10),
periodEnd: input.periodEnd.toISOString().slice(0, 10),
resource: {
id: resource.id,
eid: resource.eid,
name: resource.displayName,
country: resource.country?.name ?? resource.country?.code ?? null,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCity: resource.metroCity?.name ?? null,
},
holidays: resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
sourceType: holiday.sourceType,
})),
};
}
async function assertEntryDateAvailable(
db: HolidayCalendarDb,
input: {
@@ -153,26 +549,40 @@ async function assertScopeConsistency(
export const holidayCalendarRouter = createTRPCRouter({
listCalendars: protectedProcedure
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const where = input?.includeInactive ? undefined : { isActive: true };
.input(z.object({
includeInactive: z.boolean().optional(),
countryCode: z.string().trim().min(1).optional(),
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCity: z.string().trim().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)),
return db.holidayCalendar.findMany({
...(where ? { where } : {}),
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
listCalendarsDetail: protectedProcedure
.input(z.object({
includeInactive: z.boolean().optional(),
countryCode: z.string().trim().min(1).optional(),
scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(),
stateCode: z.string().trim().min(1).optional(),
metroCity: z.string().trim().min(1).optional(),
}).optional())
.query(async ({ ctx, input }) => {
const calendars = await readCalendarsSnapshot(ctx, input);
return {
count: calendars.length,
calendars: calendars.map(formatHolidayCalendarDetail),
};
}),
getCalendarByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)),
getCalendarByIdentifierDetail: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier);
return formatHolidayCalendarDetail(calendar);
}),
getCalendarById: protectedProcedure
@@ -323,7 +733,7 @@ export const holidayCalendarRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: existing.id, name: existing.name };
}),
createEntry: adminProcedure
@@ -430,42 +840,61 @@ export const holidayCalendarRouter = createTRPCRouter({
source: "ui",
});
return { success: true };
return { success: true, id: existing.id, name: existing.name };
}),
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays),
previewResolvedHolidaysDetail: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
count: holidays.length,
locationContext: resolved.locationContext,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
})
: null;
resolveHolidays: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)),
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
resolveHolidaysDetail: protectedProcedure
.input(ResolveHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
locationContext: resolved.locationContext,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
return resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
}));
resolveResourceHolidays: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)),
resolveResourceHolidaysDetail: protectedProcedure
.input(ResolveResourceHolidaysInputSchema)
.query(async ({ ctx, input }) => {
const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input);
const holidays = resolved.holidays.map(formatResolvedHolidayDetail);
return {
periodStart: resolved.periodStart,
periodEnd: resolved.periodEnd,
resource: resolved.resource,
count: holidays.length,
summary: summarizeResolvedHolidaysDetail(holidays),
holidays,
};
}),
});
+274 -288
View File
@@ -13,6 +13,69 @@ export interface Anomaly {
message: string;
}
interface InsightDemandRecord {
headcount: number;
startDate: Date;
endDate: Date;
_count: {
assignments: number;
};
}
interface InsightProjectAssignmentRecord {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
}
interface InsightProjectRecord {
id: string;
name: string;
budgetCents: number;
startDate: Date;
endDate: Date;
demandRequirements: InsightDemandRecord[];
assignments: InsightProjectAssignmentRecord[];
}
interface InsightResourceRecord {
id: string;
displayName: string;
availability: unknown;
}
interface InsightAssignmentLoadRecord {
resourceId: string;
hoursPerDay: number;
}
interface InsightSnapshot {
anomalies: Anomaly[];
summary: {
total: number;
criticalCount: number;
budget: number;
staffing: number;
timeline: number;
utilization: number;
};
}
interface InsightsDbAccess {
project: {
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
@@ -29,9 +92,216 @@ function countBusinessDays(start: Date, end: Date): number {
return count;
}
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
return db.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
}) as Promise<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return db.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
}) as Promise<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
summary.total += 1;
summary[anomaly.type] += 1;
if (anomaly.severity === "critical") {
summary.criticalCount += 1;
}
return summary;
}, {
total: 0,
criticalCount: 0,
budget: 0,
staffing: 0,
timeline: 0,
utilization: 0,
});
}
async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise<InsightSnapshot> {
const [projects, resources, activeAssignments] = await Promise.all([
loadInsightProjects(db.project),
loadInsightResources(db.resource),
loadInsightAssignmentLoads(db.assignment, now),
]);
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const assignmentStart = assignment.startDate < project.startDate
? project.startDate
: assignment.startDate;
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
if (assignmentEnd < assignmentStart) {
return sum;
}
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
const overrunAssignments = project.assignments.filter(
(assignment) => assignment.endDate > project.endDate
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
}
for (const resource of resources) {
const availability = resource.availability as Record<string, number> | null;
if (!availability) {
continue;
}
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
if (dailyAvailableHours <= 0) {
continue;
}
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((left, right) => {
if (left.severity !== right.severity) {
return left.severity === "critical" ? -1 : 1;
}
return left.type.localeCompare(right.type);
});
return {
anomalies,
summary: summarizeAnomalies(anomalies),
};
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const insightsRouter = createTRPCRouter({
getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => {
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return {
anomalies: snapshot.anomalies,
count: snapshot.anomalies.length,
};
}),
/**
* Generate an AI-powered executive narrative for a project.
* Caches the result in the project's dynamicFields.aiNarrative to avoid
@@ -185,300 +455,16 @@ ${dataContext}`;
* No AI involved — pure data analysis.
*/
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
// Fetch all active projects with their demands and assignments
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
id: true,
headcount: true,
startDate: true,
endDate: true,
status: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
id: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
for (const project of projects) {
// ── Budget anomaly: spending faster than expected burn rate ──
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
const days = countBusinessDays(aStart, aEnd);
return s + a.dailyCostCents * days;
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
// ── Staffing anomaly: unfilled demands close to start ──
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
// ── Timeline anomaly: assignments extending beyond project end ──
const overrunAssignments = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
// ── Utilization anomaly: resources at extreme utilization ──
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
});
// Get all active assignments for current period
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
});
// Build resource utilization map
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const current = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay);
}
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvailHours <= 0) continue;
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && utilizationPercent > 0) {
// Only flag under-utilization if resource has at least some bookings
// to avoid flagging bench resources
if (bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
}
}
}
// Sort: critical first, then by type
anomalies.sort((a, b) => {
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
return a.type.localeCompare(b.type);
});
return anomalies;
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.anomalies;
}),
/**
* Dashboard-friendly summary: anomaly counts by category + total.
*/
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
// Re-use the detectAnomalies logic inline (calling it directly would
// require the full context to be passed through — simpler to share code
// via the router caller pattern, but for now we duplicate the call).
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
let budgetCount = 0;
let staffingCount = 0;
let timelineCount = 0;
let criticalCount = 0;
for (const project of projects) {
// Budget check
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBusinessDays(aStart, aEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
budgetCount++;
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
}
}
}
// Staffing check
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
if (unfillPct > 0.3) {
staffingCount++;
if (unfillPct > 0.6) criticalCount++;
}
}
// Timeline check
const overruns = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overruns.length > 0) timelineCount++;
}
// Utilization check
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, availability: true },
});
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { resourceId: true, hoursPerDay: true },
});
const resourceHoursMap = new Map<string, number>();
for (const a of activeAssignments) {
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
}
let utilizationCount = 0;
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvail <= 0) continue;
const booked = resourceHoursMap.get(resource.id) ?? 0;
const pct = Math.round((booked / dailyAvail) * 100);
if (pct > 110) {
utilizationCount++;
if (pct > 130) criticalCount++;
} else if (pct < 40 && booked > 0) {
utilizationCount++;
}
}
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
return {
total,
criticalCount,
budget: budgetCount,
staffing: staffingCount,
timeline: timelineCount,
utilization: utilizationCount,
};
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.summary;
}),
/**
+155
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import {
@@ -12,6 +13,7 @@ import {
import { createNotification } from "../lib/create-notification.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import { sendEmail } from "../lib/email.js";
import { getTaskAction } from "../lib/task-actions.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -260,6 +262,49 @@ export const notificationRouter = createTRPCRouter({
};
}),
/** Get one task/approval visible to the current user */
getTaskDetail: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
title: true,
body: true,
type: true,
priority: true,
category: true,
taskStatus: true,
taskAction: true,
dueDate: true,
entityId: true,
entityType: true,
completedAt: true,
completedBy: true,
createdAt: true,
userId: true,
assigneeId: true,
sender: { select: { id: true, name: true, email: true } },
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
return task;
}),
/** Update task status */
updateTaskStatus: protectedProcedure
.input(
@@ -312,6 +357,101 @@ export const notificationRouter = createTRPCRouter({
return updated;
}),
/** Execute the machine-readable action associated with a task */
executeTaskAction: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const task = await ctx.db.notification.findFirst({
where: {
id: input.id,
OR: [{ userId }, { assigneeId: userId }],
category: { in: ["TASK", "APPROVAL"] },
},
select: {
id: true,
userId: true,
assigneeId: true,
taskAction: true,
taskStatus: true,
},
});
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Task not found or you do not have permission",
});
}
if (!task.taskAction) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task has no executable action",
});
}
if (task.taskStatus === "DONE") {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "This task is already completed",
});
}
const parsed = parseTaskAction(task.taskAction);
if (!parsed) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid taskAction format: ${task.taskAction}`,
});
}
const handler = getTaskAction(parsed.action);
if (!handler) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown action: ${parsed.action}`,
});
}
const permissions = resolvePermissions(
ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole,
ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (handler.permission && !permissions.has(handler.permission as PermissionKey)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission denied: you need "${handler.permission}" to perform this action`,
});
}
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
if (!actionResult.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: actionResult.message,
});
}
const completedTask = await ctx.db.notification.update({
where: { id: input.id },
data: {
taskStatus: "DONE",
completedAt: new Date(),
completedBy: userId,
},
});
emitTaskCompleted(task.userId, task.id);
if (task.assigneeId && task.assigneeId !== task.userId) {
emitTaskCompleted(task.assigneeId, task.id);
}
return {
task: completedTask,
actionResult,
};
}),
// ═══════════════════════════════════════════════════════════════════════════
// REMINDERS
// ═══════════════════════════════════════════════════════════════════════════
@@ -542,6 +682,21 @@ export const notificationRouter = createTRPCRouter({
});
}),
/** Get one broadcast with sender context */
getBroadcastById: managerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return findUniqueOrThrow(
ctx.db.notificationBroadcast.findUnique({
where: { id: input.id },
include: {
sender: { select: { id: true, name: true, email: true } },
},
}),
"Broadcast",
);
}),
// ═══════════════════════════════════════════════════════════════════════════
// TASK CREATION (Manager+)
// ═══════════════════════════════════════════════════════════════════════════
+92
View File
@@ -78,6 +78,98 @@ export const orgUnitRouter = createTRPCRouter({
return unit;
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
shortName: true,
level: true,
isActive: true,
} as const;
let unit = await ctx.db.orgUnit.findUnique({
where: { id: identifier },
select,
});
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { shortName: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ shortName: { contains: identifier, mode: "insensitive" } },
],
},
select,
});
}
if (!unit) {
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
}
return unit;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
let unit = await ctx.db.orgUnit.findUnique({
where: { id: identifier },
include: { _count: { select: { resources: true } } },
});
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: { shortName: { equals: identifier, mode: "insensitive" } },
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
unit = await ctx.db.orgUnit.findFirst({
where: {
OR: [
{ name: { contains: identifier, mode: "insensitive" } },
{ shortName: { contains: identifier, mode: "insensitive" } },
],
},
include: { _count: { select: { resources: true } } },
});
}
if (!unit) {
throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` });
}
return unit;
}),
create: adminProcedure
.input(CreateOrgUnitSchema)
.mutation(async ({ ctx, input }) => {
+486 -11
View File
@@ -16,17 +16,481 @@ import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import type { TRPCContext } from "../trpc.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
const PROJECT_SUMMARY_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
client: { select: { name: true } },
} as const;
const PROJECT_SUMMARY_DETAIL_SELECT = {
...PROJECT_SUMMARY_SELECT,
budgetCents: true,
winProbability: true,
_count: { select: { assignments: true, estimates: true } },
} as const;
const PROJECT_IDENTIFIER_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
} as const;
const PROJECT_DETAIL_SELECT = {
...PROJECT_IDENTIFIER_SELECT,
id: true,
shortCode: true,
name: true,
status: true,
orderType: true,
allocationType: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
responsiblePerson: true,
client: { select: { name: true } },
utilizationCategory: { select: { code: true, name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
function runProjectBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Project background side effect failed",
);
});
}
function invalidateDashboardCacheInBackground(): void {
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
}
function dispatchProjectWebhookInBackground(
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
function formatDate(value: Date | null): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function mapProjectSummary(project: {
id: string;
shortCode: string;
name: string;
status: string;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
};
}
function mapProjectSummaryDetail(project: {
id: string;
shortCode: string;
name: string;
status: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
_count: { assignments: number; estimates: number };
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
};
}
function mapProjectDetail(
project: {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
allocationType: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
responsiblePerson: string | null;
client: { name: string } | null;
utilizationCategory: { code: string; name: string } | null;
_count: { assignments: number; estimates: number };
},
topAssignments: Array<{
resource: { displayName: string; eid: string };
role: string | null;
status: string;
hoursPerDay: number;
startDate: Date;
endDate: Date;
}>,
) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
orderType: project.orderType,
allocationType: project.allocationType,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
budgetCents: project.budgetCents,
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
topAllocations: topAssignments.map((assignment) => ({
resource: assignment.resource.displayName,
eid: assignment.resource.eid,
role: assignment.role ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
start: formatDate(assignment.startDate),
end: formatDate(assignment.endDate),
})),
};
}
async function readProjectSummariesSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function readProjectSummaryDetailsSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function resolveProjectIdentifierSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
let project = await ctx.db.project.findUnique({
where: { id: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}
async function readProjectByIdentifierDetailSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier);
const project = await ctx.db.project.findUnique({
where: { id: projectIdentity.id },
select: PROJECT_DETAIL_SELECT,
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const topAssignments = await ctx.db.assignment.findMany({
where: {
projectId: project.id,
status: { not: "CANCELLED" },
},
select: {
resource: { select: { displayName: true, eid: true } },
role: true,
status: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
take: 10,
orderBy: { startDate: "desc" },
});
return {
...project,
topAssignments,
};
}
export const projectRouter = createTRPCRouter({
list: protectedProcedure
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
shortCode: true,
name: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
} as const;
let project = await ctx.db.project.findUnique({
where: { id: input.identifier },
select,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: input.identifier },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}),
searchSummaries: protectedProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input);
const formatted = items.map(mapProjectSummary);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
searchSummariesDetail: controllerProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input);
const formatted = items.map(mapProjectSummaryDetail);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
list: controllerProcedure
.input(
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
@@ -90,7 +554,7 @@ export const projectRouter = createTRPCRouter({
};
}),
getById: protectedProcedure
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
@@ -113,7 +577,18 @@ export const projectRouter = createTRPCRouter({
};
}),
getShoringRatio: protectedProcedure
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
getByIdentifierDetail: controllerProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier);
return mapProjectDetail(project, project.topAssignments);
}),
getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
@@ -241,8 +716,8 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.created", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.created", {
id: project.id,
shortCode: project.shortCode,
name: project.name,
@@ -302,7 +777,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return updated;
}),
@@ -314,8 +789,8 @@ export const projectRouter = createTRPCRouter({
where: { id: input.id },
data: { status: input.status },
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.status_changed", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", {
id: result.id,
shortCode: result.shortCode,
name: result.name,
@@ -348,7 +823,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: updated.length };
}),
@@ -454,7 +929,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { id: input.id, name: project.name };
}),
@@ -494,7 +969,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: projects.length };
}),
+239 -1
View File
@@ -11,6 +11,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { createAuditEntry } from "../lib/audit.js";
import { fmtEur } from "../lib/format-utils.js";
const lineSelect = {
id: true,
@@ -30,6 +31,118 @@ const lineSelect = {
updatedAt: true,
} as const;
async function lookupBestRateMatch(
db: Pick<import("@capakraken/db").PrismaClient, "rateCard" | "role">,
input: {
clientId?: string | undefined;
chapter?: string | undefined;
managementLevelId?: string | undefined;
roleName?: string | undefined;
seniority?: string | undefined;
},
) {
const rateCardWhere: Prisma.RateCardWhereInput = { isActive: true };
if (input.clientId) {
rateCardWhere.OR = [
{ clientId: input.clientId },
{ clientId: null },
];
}
const rateCards = await db.rateCard.findMany({
where: rateCardWhere,
include: {
lines: {
select: {
id: true,
chapter: true,
seniority: true,
costRateCents: true,
billRateCents: true,
role: { select: { id: true, name: true } },
},
},
client: { select: { id: true, name: true } },
},
orderBy: [{ effectiveFrom: "desc" }],
});
if (rateCards.length === 0) {
return {
bestMatch: null,
alternatives: [],
totalCandidates: 0,
message: "No active rate cards found.",
};
}
let roleId: string | undefined;
if (input.roleName) {
const role = await db.role.findFirst({
where: { name: { contains: input.roleName, mode: "insensitive" } },
select: { id: true },
});
if (role) roleId = role.id;
}
const scoredLines: Array<{
rateCardName: string;
clientId: string | null;
clientName: string | null;
lineId: string;
chapter: string | null;
seniority: string | null;
roleName: string | null;
costRateCents: number;
billRateCents: number | null;
score: number;
}> = [];
for (const card of rateCards) {
for (const line of card.lines) {
let score = 0;
let mismatch = false;
if (roleId && line.role) {
if (line.role.id === roleId) score += 4;
else mismatch = true;
}
if (input.chapter && line.chapter) {
if (line.chapter.toLowerCase() === input.chapter.toLowerCase()) score += 2;
else mismatch = true;
}
if (input.seniority && line.seniority) {
if (line.seniority.toLowerCase() === input.seniority.toLowerCase()) score += 1;
else mismatch = true;
}
if (input.clientId && card.client?.id === input.clientId) score += 3;
if (!mismatch) {
scoredLines.push({
rateCardName: card.name,
clientId: card.client?.id ?? null,
clientName: card.client?.name ?? null,
lineId: line.id,
chapter: line.chapter,
seniority: line.seniority,
roleName: line.role?.name ?? null,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents ?? null,
score,
});
}
}
}
scoredLines.sort((a, b) => b.score - a.score);
return {
bestMatch: scoredLines[0] ?? null,
alternatives: scoredLines.slice(1, 4),
totalCandidates: scoredLines.length,
};
}
export const rateCardRouter = createTRPCRouter({
list: controllerProcedure
.input(
@@ -92,6 +205,131 @@ export const rateCardRouter = createTRPCRouter({
return rateCard;
}),
lookupBestMatch: controllerProcedure
.input(z.object({
clientId: z.string().optional(),
chapter: z.string().optional(),
managementLevelId: z.string().optional(),
roleName: z.string().optional(),
seniority: z.string().optional(),
}))
.query(async ({ ctx, input }) => lookupBestRateMatch(ctx.db, input)),
resolveBestRate: controllerProcedure
.input(z.object({
resourceId: z.string().optional(),
roleName: z.string().optional(),
date: z.coerce.date().optional(),
}))
.query(async ({ ctx, input }) => {
const effectiveAt = input.date ?? new Date();
if (input.resourceId) {
const resource = await findUniqueOrThrow(
ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chapter: true,
areaRole: { select: { name: true } },
},
}),
"Resource",
);
const resolved = await lookupBestRateMatch(ctx.db, {
...(resource.chapter ? { chapter: resource.chapter } : {}),
...(resource.areaRole?.name ? { roleName: resource.areaRole.name } : {}),
});
if (resolved.bestMatch) {
return {
rateCard: resolved.bestMatch.rateCardName,
resource: resource.displayName,
rate: fmtEur(resolved.bestMatch.costRateCents),
rateCents: resolved.bestMatch.costRateCents,
matchedBy: resolved.bestMatch.roleName ? `role: ${resolved.bestMatch.roleName}` : "best_match",
};
}
}
if (input.roleName) {
const match = await lookupBestRateMatch(ctx.db, { roleName: input.roleName });
if (match.bestMatch) {
return {
rateCard: match.bestMatch.rateCardName,
rate: fmtEur(match.bestMatch.costRateCents),
rateCents: match.bestMatch.costRateCents,
matchedBy: match.bestMatch.roleName ? `role: ${match.bestMatch.roleName}` : "best_match",
alternatives: match.alternatives.map((alternative) => ({
rateCard: alternative.rateCardName,
role: alternative.roleName,
chapter: alternative.chapter,
seniority: alternative.seniority,
costRate: fmtEur(alternative.costRateCents),
billRate: alternative.billRateCents != null ? fmtEur(alternative.billRateCents) : null,
})),
};
}
if (match.totalCandidates === 0) {
return { error: "No matching rate card line found." };
}
}
const cards = await ctx.db.rateCard.findMany({
where: {
isActive: true,
OR: [
{ effectiveFrom: null },
{ effectiveFrom: { lte: effectiveAt } },
],
AND: [
{
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: effectiveAt } },
],
},
],
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
const card = cards[0];
if (!card) {
return { error: "No active rate card found for the given date." };
}
const detail = await findUniqueOrThrow(
ctx.db.rateCard.findUnique({
where: { id: card.id },
include: {
client: { select: { id: true, name: true, code: true } },
lines: {
select: lineSelect,
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
},
},
}),
"Rate card",
);
return {
rateCard: detail.name,
lines: detail.lines.map((line) => ({
role: line.role?.name ?? null,
seniority: line.seniority,
chapter: line.chapter,
location: line.location,
costRate: fmtEur(line.costRateCents),
billRate: line.billRateCents != null ? fmtEur(line.billRateCents) : null,
})),
};
}),
create: managerProcedure
.input(CreateRateCardSchema)
.mutation(async ({ ctx, input }) => {
@@ -362,7 +600,7 @@ export const rateCardRouter = createTRPCRouter({
// ─── Rate resolution ───────────────────────────────────────────────────────
resolveRate: controllerProcedure
resolveRateLine: controllerProcedure
.input(z.object({
rateCardId: z.string(),
roleId: z.string().optional(),
+189 -24
View File
@@ -163,6 +163,7 @@ const ENTITY_MAP = {
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
@@ -190,6 +191,158 @@ function getValidScalarField(entity: EntityKey, field: string): string | null {
return null;
}
function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined {
return COLUMN_MAP[entity].find((column) => column.key === columnKey);
}
function assertKnownColumns(entity: EntityKey, columns: string[]): void {
const invalidColumns = columns.filter((column) => !getColumnDef(entity, column));
if (invalidColumns.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`,
});
}
}
function assertValidFilterField(entity: EntityKey, field: string): string {
if (entity === "resource_month") {
if (!getColumnDef(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${entity}: ${field}`,
});
}
return field;
}
const validField = getValidScalarField(entity, field);
if (!validField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported filter field for ${entity}: ${field}`,
});
}
return validField;
}
function assertValidSortField(entity: EntityKey, field: string): void {
if (entity === "resource_month") {
if (!getColumnDef(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown sort field for ${entity}: ${field}`,
});
}
return;
}
if (!getValidScalarField(entity, field)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported sort field for ${entity}: ${field}`,
});
}
}
function assertValidGroupField(entity: EntityKey, field: string): void {
const knownField =
entity === "resource_month"
? getColumnDef(entity, field)?.key
: getValidScalarField(entity, field);
if (!knownField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported group field for ${entity}: ${field}`,
});
}
}
function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown {
if (def.dataType === "number") {
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid numeric filter value for ${def.key}: ${value}`,
});
}
return parsed;
}
if (def.dataType === "boolean") {
if (value !== "true" && value !== "false") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid boolean filter value for ${def.key}: ${value}`,
});
}
return value === "true";
}
if (def.dataType === "date") {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid date filter value for ${def.key}: ${value}`,
});
}
return parsed;
}
return value;
}
function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
assertKnownColumns(input.entity, input.columns);
if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`,
});
}
if (input.entity !== "resource_month" && input.periodMonth) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "periodMonth is only supported for resource_month reports",
});
}
if (input.sortBy) {
assertValidSortField(input.entity, input.sortBy);
}
if (input.groupBy) {
assertValidGroupField(input.entity, input.groupBy);
}
for (const filter of input.filters) {
const field = assertValidFilterField(input.entity, filter.field);
const def = getColumnDef(input.entity, field);
if (!def) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${input.entity}: ${filter.field}`,
});
}
if (filter.op === "contains" || filter.op === "in") {
if (def.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`,
});
}
continue;
}
void parseFilterValueOrThrow(def, filter.value);
}
}
/**
* Build a Prisma `select` object from the requested columns.
* Always includes `id`. For relation columns like "country.name",
@@ -254,24 +407,15 @@ function buildWhere(
const where: Record<string, unknown> = {};
for (const filter of filters) {
const field = getValidScalarField(entity, filter.field);
if (!field) continue;
const entityColumns = COLUMN_MAP[entity];
const colDef = entityColumns.find((c) => c.key === field);
const dataType = colDef?.dataType ?? "string";
// Parse value based on data type
let parsedValue: unknown = filter.value;
if (dataType === "number") {
parsedValue = Number(filter.value);
if (Number.isNaN(parsedValue as number)) continue;
} else if (dataType === "boolean") {
parsedValue = filter.value === "true";
} else if (dataType === "date") {
parsedValue = new Date(filter.value);
if (Number.isNaN((parsedValue as Date).getTime())) continue;
const field = assertValidFilterField(entity, filter.field);
const colDef = getColumnDef(entity, field);
if (!colDef) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown filter field for ${entity}: ${filter.field}`,
});
}
const parsedValue = parseFilterValueOrThrow(colDef, filter.value);
switch (filter.op) {
case "eq":
@@ -293,14 +437,28 @@ function buildWhere(
where[field] = { lte: parsedValue };
break;
case "contains":
if (dataType === "string") {
where[field] = { contains: filter.value, mode: "insensitive" };
if (colDef.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator contains is only supported for string fields like ${field}`,
});
}
where[field] = { contains: filter.value, mode: "insensitive" };
break;
case "in":
if (dataType === "string") {
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
if (colDef.dataType !== "string") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Filter operator in is only supported for string fields like ${field}`,
});
}
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
break;
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported filter operator: ${filter.op}`,
});
break;
}
}
@@ -355,7 +513,7 @@ const ReportInputSchema = z.object({
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
@@ -440,6 +598,7 @@ export const reportRouter = createTRPCRouter({
config: ReportTemplateConfigSchema,
}))
.mutation(async ({ ctx, input }) => {
validateReportInput(input.config);
const reportTemplate = getReportTemplateDelegate(ctx.db);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
@@ -568,6 +727,8 @@ async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
validateReportInput(input);
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
}
@@ -579,9 +740,13 @@ async function executeReportQuery(
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
if (!validField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported sort field for ${entity}: ${sortBy}`,
});
}
orderBy = { [validField]: sortDir };
}
const modelDelegate = getModelDelegate(db, entity);
File diff suppressed because it is too large Load Diff
+81
View File
@@ -80,6 +80,87 @@ export const roleRouter = createTRPCRouter({
return attachPlanningEntryCounts(ctx.db, roles);
}),
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => {
const identifier = input.identifier.trim();
const select = {
id: true,
name: true,
color: true,
isActive: true,
} as const;
let role = await ctx.db.role.findUnique({
where: { id: identifier },
select,
});
if (!role) {
role = await ctx.db.role.findUnique({
where: { name: identifier },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return role;
}),
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: { select: { resourceRoles: true } },
} as const;
let role = await ctx.db.role.findUnique({
where: { id: input.identifier },
select,
});
if (!role) {
role = await ctx.db.role.findUnique({
where: { name: input.identifier },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
role = await ctx.db.role.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return attachSinglePlanningEntryCount(ctx.db, role);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
File diff suppressed because it is too large Load Diff
+565 -41
View File
@@ -8,10 +8,10 @@ import {
updateDemandRequirement,
updateAllocationEntry,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import { VacationType } from "@capakraken/db";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -28,8 +28,10 @@ import {
} from "../sse/event-bus.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
type ShiftDbClient = Pick<
PrismaClient,
@@ -52,6 +54,20 @@ export type TimelineEntriesFilters = {
countryCodes?: string[] | undefined;
};
const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
});
type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;
export function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
@@ -64,6 +80,215 @@ export function getAssignmentResourceIds(
];
}
function fmtDate(value: Date | null | undefined): string | null {
if (!value) {
return null;
}
return value.toISOString().slice(0, 10);
}
function createUtcDate(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
function createTimelineDateRange(input: {
startDate?: string | undefined;
endDate?: string | undefined;
durationDays?: number | undefined;
}): { startDate: Date; endDate: Date } {
const now = new Date();
const startDate = input.startDate
? new Date(`${input.startDate}T00:00:00.000Z`)
: createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
if (Number.isNaN(startDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid startDate: ${input.startDate}`,
});
}
const endDate = input.endDate
? new Date(`${input.endDate}T00:00:00.000Z`)
: createUtcDate(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
);
if (Number.isNaN(endDate.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid endDate: ${input.endDate}`,
});
}
if (endDate < startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
return { startDate, endDate };
}
function normalizeStringList(values?: string[] | undefined): string[] | undefined {
const normalized = values
?.map((value) => value.trim())
.filter((value) => value.length > 0);
return normalized && normalized.length > 0 ? normalized : undefined;
}
function createTimelineFilters(input: {
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
clientIds?: string[] | undefined;
chapters?: string[] | undefined;
eids?: string[] | undefined;
countryCodes?: string[] | undefined;
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
return {
resourceIds: normalizeStringList(input.resourceIds),
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
chapters: normalizeStringList(input.chapters),
eids: normalizeStringList(input.eids),
countryCodes: normalizeStringList(input.countryCodes),
};
}
function createEmptyTimelineEntriesView() {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [],
});
}
async function findOwnedTimelineResourceId(
ctx: TimelineSelfServiceContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function buildSelfServiceTimelineInput(
ctx: TimelineSelfServiceContext,
input: TimelineWindowFiltersInput,
): Promise<TimelineEntriesFilters | null> {
const ownedResourceId = await findOwnedTimelineResourceId(ctx);
if (!ownedResourceId) {
return null;
}
return {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [ownedResourceId],
projectIds: normalizeStringList(input.projectIds),
clientIds: normalizeStringList(input.clientIds),
};
}
function summarizeTimelineEntries(readModel: {
allocations: Array<{ projectId: string | null; resourceId: string | null }>;
demands: Array<{ projectId: string | null }>;
assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
const projectIds = new Set<string>();
const resourceIds = new Set<string>();
for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
if (entry.projectId) {
projectIds.add(entry.projectId);
}
}
for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
if (assignment.resourceId) {
resourceIds.add(assignment.resourceId);
}
}
return {
allocationCount: readModel.allocations.length,
demandCount: readModel.demands.length,
assignmentCount: readModel.assignments.length,
projectCount: projectIds.size,
resourceCount: resourceIds.size,
};
}
function formatHolidayOverlays(
overlays: Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
note?: string | null;
scope?: string | null;
calendarName?: string | null;
sourceType?: string | null;
}>,
) {
return overlays.map((overlay) => ({
id: overlay.id,
resourceId: overlay.resourceId,
startDate: fmtDate(overlay.startDate),
endDate: fmtDate(overlay.endDate),
note: overlay.note ?? null,
scope: overlay.scope ?? null,
calendarName: overlay.calendarName ?? null,
sourceType: overlay.sourceType ?? null,
}));
}
function summarizeHolidayOverlays(
overlays: ReturnType<typeof formatHolidayOverlays>,
) {
const resourceIds = new Set<string>();
const byScope = new Map<string, number>();
for (const overlay of overlays) {
resourceIds.add(overlay.resourceId);
const scope = overlay.scope ?? "UNKNOWN";
byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
}
return {
overlayCount: overlays.length,
holidayResourceCount: resourceIds.size,
byScope: [...byScope.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([scope, count]) => ({ scope, count })),
};
}
function rangesOverlap(
leftStart: Date,
leftEnd: Date,
rightStart: Date,
rightEnd: Date,
): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
export async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
@@ -147,6 +372,14 @@ export async function loadTimelineHolidayOverlays(
input: TimelineEntriesFilters,
) {
const readModel = await loadTimelineEntriesReadModel(db, input);
return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}
async function loadTimelineHolidayOverlaysForReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
const resourceIds = [...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
@@ -380,17 +613,56 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
}
/** Load active calculation rules from DB, falling back to defaults if none configured. */
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code !== "P2021") {
return false;
}
const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
const message = error.message.toLowerCase();
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: { table?: unknown };
};
const code = typeof candidate.code === "string" ? candidate.code : "";
if (code !== "P2021") {
return false;
}
const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
const calculationRuleModel = (db as PrismaClient & {
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
}).calculationRule;
if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
return DEFAULT_CALCULATION_RULES;
}
try {
const rules = await db.calculationRule.findMany({
const rules = await calculationRuleModel.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (rules.length > 0) {
return rules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
} catch (error) {
if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
logger.error({ err: error }, "Failed to load active calculation rules for timeline");
throw error;
}
}
return DEFAULT_CALCULATION_RULES;
}
@@ -440,8 +712,14 @@ async function buildAbsenceDays(
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet
} catch (error) {
if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
logger.error(
{ err: error, resourceId, startDate, endDate },
"Failed to load timeline absence days",
);
throw error;
}
}
return { absenceDays, legacyVacationDates };
@@ -452,38 +730,16 @@ export const timelineRouter = createTRPCRouter({
* Get all timeline entries (projects + allocations) for a date range.
* Includes project startDate, endDate, staffingReqs for demand overlay.
*/
getEntries: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
getEntries: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
const directory = await getAnonymizationDirectory(ctx.db);
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
}),
getEntriesView: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
getEntriesView: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, input),
@@ -497,11 +753,47 @@ export const timelineRouter = createTRPCRouter({
};
}),
getHolidayOverlays: protectedProcedure
getMyEntriesView: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return createEmptyTimelineEntriesView();
}
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
getAnonymizationDirectory(ctx.db),
]);
return {
...readModel,
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
};
}),
getHolidayOverlays: controllerProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
getMyHolidayOverlays: protectedProcedure
.input(TimelineWindowFiltersSchema)
.query(async ({ ctx, input }) => {
const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
if (!selfServiceInput) {
return [];
}
return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
}),
getEntriesDetail: controllerProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
@@ -510,7 +802,73 @@ export const timelineRouter = createTRPCRouter({
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const timelineInput = { ...filters, startDate, endDate };
const [readModel, directory] = await Promise.all([
loadTimelineEntriesReadModel(ctx.db, timelineInput),
getAnonymizationDirectory(ctx.db),
]);
const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
ctx.db,
timelineInput,
readModel,
);
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: {
...summarizeTimelineEntries(readModel),
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
demands: readModel.demands,
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
holidayOverlays: formattedHolidayOverlays,
};
}),
getHolidayOverlayDetail: controllerProcedure
.input(
z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = createTimelineDateRange(input);
const filters = createTimelineFilters(input);
const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
...filters,
startDate,
endDate,
});
const formattedOverlays = formatHolidayOverlays(holidayOverlays);
return {
period: {
startDate: fmtDate(startDate),
endDate: fmtDate(endDate),
},
filters,
summary: summarizeHolidayOverlays(formattedOverlays),
overlays: formattedOverlays,
};
}),
/**
* Get full project context for a project:
@@ -519,7 +877,7 @@ export const timelineRouter = createTRPCRouter({
* - all assignment bookings for the same resources (for cross-project overlap display)
* Used when: drag starts or project panel opens.
*/
getProjectContext: protectedProcedure
getProjectContext: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const {
@@ -548,6 +906,122 @@ export const timelineRouter = createTRPCRouter({
};
}),
getProjectContextDetail: controllerProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.string().optional(),
endDate: z.string().optional(),
durationDays: z.number().int().min(1).max(366).optional(),
}),
)
.query(async ({ ctx, input }) => {
const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
const directory = await getAnonymizationDirectory(ctx.db);
const derivedStartDate = input.startDate
? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
: projectContext.project.startDate
?? projectContext.assignments[0]?.startDate
?? projectContext.demands[0]?.startDate
?? createTimelineDateRange({ durationDays: 1 }).startDate;
const derivedEndDate = input.endDate
? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
: projectContext.project.endDate
?? createTimelineDateRange({
startDate: fmtDate(derivedStartDate) ?? undefined,
durationDays: input.durationDays ?? 21,
}).endDate;
if (derivedEndDate < derivedStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "endDate must be on or after startDate.",
});
}
const holidayOverlays = projectContext.resourceIds.length > 0
? await loadTimelineHolidayOverlays(ctx.db, {
startDate: derivedStartDate,
endDate: derivedEndDate,
resourceIds: projectContext.resourceIds,
projectIds: [input.projectId],
})
: [];
const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);
const assignmentConflicts = projectContext.assignments
.filter((assignment) => assignment.resourceId && assignment.resource)
.map((assignment) => {
const overlaps = projectContext.allResourceAllocations
.filter((booking) => (
booking.resourceId === assignment.resourceId
&& booking.id !== assignment.id
&& rangesOverlap(
toDate(booking.startDate),
toDate(booking.endDate),
toDate(assignment.startDate),
toDate(assignment.endDate),
)
))
.map((booking) => ({
id: booking.id,
projectId: booking.projectId,
projectName: booking.project?.name ?? null,
projectShortCode: booking.project?.shortCode ?? null,
startDate: fmtDate(toDate(booking.startDate)),
endDate: fmtDate(toDate(booking.endDate)),
hoursPerDay: booking.hoursPerDay,
status: booking.status,
sameProject: booking.projectId === input.projectId,
}));
return {
assignmentId: assignment.id,
resourceId: assignment.resourceId!,
resourceName: assignment.resource?.displayName ?? null,
startDate: fmtDate(toDate(assignment.startDate)),
endDate: fmtDate(toDate(assignment.endDate)),
hoursPerDay: assignment.hoursPerDay,
overlapCount: overlaps.length,
crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
overlaps,
};
});
return {
project: projectContext.project,
period: {
startDate: fmtDate(derivedStartDate),
endDate: fmtDate(derivedEndDate),
},
summary: {
...summarizeTimelineEntries({
allocations: projectContext.allocations,
demands: projectContext.demands,
assignments: projectContext.assignments,
}),
resourceIds: projectContext.resourceIds.length,
allResourceAllocationCount: projectContext.allResourceAllocations.length,
conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
...summarizeHolidayOverlays(formattedHolidayOverlays),
},
allocations: projectContext.allocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
demands: projectContext.demands,
assignments: projectContext.assignments.map((assignment) =>
anonymizeResourceOnEntry(assignment, directory),
),
allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
anonymizeResourceOnEntry(allocation, directory),
),
assignmentConflicts,
holidayOverlays: formattedHolidayOverlays,
resourceIds: projectContext.resourceIds,
};
}),
/**
* Inline update of an allocation's hours, dates, includeSaturday, or role.
* Recalculates dailyCostCents and emits SSE.
@@ -682,10 +1156,50 @@ export const timelineRouter = createTRPCRouter({
* Preview a project shift — validate without committing.
* Returns cost impact, conflicts, warnings.
*/
previewShift: protectedProcedure
previewShift: controllerProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),
getShiftPreviewDetail: controllerProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => {
const [project, preview] = await Promise.all([
findUniqueOrThrow(
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
},
}),
"Project",
),
previewTimelineProjectShift(ctx.db, input),
]);
return {
project: {
id: project.id,
name: project.name,
shortCode: project.shortCode,
status: project.status,
responsiblePerson: project.responsiblePerson,
startDate: fmtDate(project.startDate),
endDate: fmtDate(project.endDate),
},
requestedShift: {
newStartDate: fmtDate(input.newStartDate),
newEndDate: fmtDate(input.newEndDate),
},
preview,
};
}),
/**
* Apply a project shift — validate, then commit all allocation date changes.
* Reads includeSaturday from each allocation's metadata.
@@ -1044,7 +1558,7 @@ export const timelineRouter = createTRPCRouter({
/**
* Get budget status for a project.
*/
getBudgetStatus: protectedProcedure
getBudgetStatus: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await findUniqueOrThrow(
@@ -1052,6 +1566,8 @@ export const timelineRouter = createTRPCRouter({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
winProbability: true,
startDate: true,
@@ -1066,7 +1582,7 @@ export const timelineRouter = createTRPCRouter({
projectIds: [project.id],
});
return computeBudgetStatus(
const budgetStatus = computeBudgetStatus(
project.budgetCents,
project.winProbability,
bookings.map((booking) => ({
@@ -1079,5 +1595,13 @@ export const timelineRouter = createTRPCRouter({
project.startDate,
project.endDate,
);
return {
...budgetStatus,
projectName: project.name,
projectCode: project.shortCode,
totalAllocations: bookings.length,
budgetCents: project.budgetCents,
};
}),
});
+231 -9
View File
@@ -15,14 +15,126 @@ import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
function canManageVacationReads(ctx: { dbUser: { systemRole: string } | null }): boolean {
const role = ctx.dbUser?.systemRole;
return role === "ADMIN" || role === "MANAGER";
}
function runVacationBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Vacation background side effect failed",
);
});
}
function notifyVacationStatusInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
): void {
runVacationBackgroundEffect(
"notifyVacationStatus",
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
{ vacationId, resourceId, newStatus },
);
}
function dispatchVacationWebhookInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
event: string,
payload: Record<string, unknown>,
): void {
runVacationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
async function findOwnedResourceId(
ctx: VacationReadContext,
): Promise<string | null> {
if (!ctx.dbUser?.id) {
return null;
}
if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
return null;
}
const resource = await ctx.db.resource.findFirst({
where: { userId: ctx.dbUser.id },
select: { id: true },
});
return resource?.id ?? null;
}
async function assertCanReadVacationResource(
ctx: VacationReadContext,
resourceId: string,
): Promise<void> {
if (canManageVacationReads(ctx)) {
return;
}
const ownedResourceId = await findOwnedResourceId(ctx);
if (!ownedResourceId || ownedResourceId !== resourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
}
function isSameUtcDay(left: Date, right: Date): boolean {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function mapTeamOverlapDetail(params: {
resource: { displayName: string; chapter: string | null };
startDate: Date;
endDate: Date;
overlaps: Array<{
type: VacationType;
status: VacationStatus;
startDate: Date;
endDate: Date;
resource: { displayName: string };
}>;
}) {
return {
resource: params.resource.displayName,
chapter: params.resource.chapter,
period: `${params.startDate.toISOString().slice(0, 10)} to ${params.endDate.toISOString().slice(0, 10)}`,
overlappingVacations: params.overlaps.map((vacation) => ({
resource: vacation.resource.displayName,
type: vacation.type,
status: vacation.status,
start: vacation.startDate.toISOString().slice(0, 10),
end: vacation.endDate.toISOString().slice(0, 10),
})),
overlapCount: params.overlaps.length,
};
}
const PreviewVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
@@ -224,9 +336,25 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
let resourceIdFilter = input.resourceId;
if (!canManageVacationReads(ctx)) {
const ownedResourceId = await findOwnedResourceId(ctx);
if (input.resourceId && input.resourceId !== ownedResourceId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view vacation data for your own resource",
});
}
if (!ownedResourceId) {
return [];
}
resourceIdFilter = ownedResourceId;
}
const vacations = await ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(resourceIdFilter ? { resourceId: resourceIdFilter } : {}),
...(input.status ? { status: Array.isArray(input.status) ? { in: input.status } : input.status } : {}),
...(input.type ? { type: input.type } : {}),
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
@@ -254,15 +382,38 @@ export const vacationRouter = createTRPCRouter({
ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, userId: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
}),
"Vacation",
);
if (!canManageVacationReads(ctx)) {
const isOwnVacation = vacation.resource?.userId === ctx.dbUser?.id || vacation.requestedById === ctx.dbUser?.id;
if (!isOwnVacation) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation data",
});
}
}
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
const anonymized = anonymizeVacationRecord(vacation, directory);
return {
...anonymized,
resource: anonymized.resource
? {
id: anonymized.resource.id,
displayName: anonymized.resource.displayName,
eid: anonymized.resource.eid,
lcrCents: anonymized.resource.lcrCents,
chapter: anonymized.resource.chapter,
}
: null,
};
}),
/**
@@ -475,7 +626,7 @@ export const vacationRouter = createTRPCRouter({
summary: `Approved vacation (was ${existing.status})`,
});
void dispatchWebhooks(ctx.db, "vacation.approved", {
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
@@ -497,7 +648,7 @@ export const vacationRouter = createTRPCRouter({
});
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return { ...updated, warnings: conflictResult.warnings };
@@ -558,7 +709,13 @@ export const vacationRouter = createTRPCRouter({
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
});
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
updated.id,
updated.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
return updated;
}),
@@ -599,7 +756,7 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
notifyVacationStatusInBackground(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
void createAuditEntry({
db: ctx.db,
@@ -668,7 +825,13 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
notifyVacationStatusInBackground(
ctx.db,
v.id,
v.resourceId,
VacationStatus.REJECTED,
input.rejectionReason,
);
void createAuditEntry({
db: ctx.db,
@@ -773,6 +936,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
return ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -798,7 +963,7 @@ export const vacationRouter = createTRPCRouter({
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
@@ -818,6 +983,8 @@ export const vacationRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
// Find the chapter of the requesting resource
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
@@ -842,6 +1009,61 @@ export const vacationRouter = createTRPCRouter({
});
}),
getTeamOverlapDetail: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
await assertCanReadVacationResource(ctx, input.resourceId);
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, chapter: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
if (!resource.chapter) {
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps: [],
});
}
const overlaps = await ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter },
resourceId: { not: input.resourceId },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
},
orderBy: { startDate: "asc" },
take: 20,
});
return mapTeamOverlapDetail({
resource,
startDate: input.startDate,
endDate: input.endDate,
overlaps,
});
}),
/**
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
* Admin-only. Creates as APPROVED automatically.