feat(platform): harden access scoping and delivery baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+3322
-5125
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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+)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1207
-75
File diff suppressed because it is too large
Load Diff
@@ -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 }) => {
|
||||
|
||||
+971
-290
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user