feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+274 -288
View File
@@ -13,6 +13,69 @@ export interface Anomaly {
message: string;
}
interface InsightDemandRecord {
headcount: number;
startDate: Date;
endDate: Date;
_count: {
assignments: number;
};
}
interface InsightProjectAssignmentRecord {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
}
interface InsightProjectRecord {
id: string;
name: string;
budgetCents: number;
startDate: Date;
endDate: Date;
demandRequirements: InsightDemandRecord[];
assignments: InsightProjectAssignmentRecord[];
}
interface InsightResourceRecord {
id: string;
displayName: string;
availability: unknown;
}
interface InsightAssignmentLoadRecord {
resourceId: string;
hoursPerDay: number;
}
interface InsightSnapshot {
anomalies: Anomaly[];
summary: {
total: number;
criticalCount: number;
budget: number;
staffing: number;
timeline: number;
utilization: number;
};
}
interface InsightsDbAccess {
project: {
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
@@ -29,9 +92,216 @@ function countBusinessDays(start: Date, end: Date): number {
return count;
}
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
return db.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
}) as Promise<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return db.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
}) as Promise<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
summary.total += 1;
summary[anomaly.type] += 1;
if (anomaly.severity === "critical") {
summary.criticalCount += 1;
}
return summary;
}, {
total: 0,
criticalCount: 0,
budget: 0,
staffing: 0,
timeline: 0,
utilization: 0,
});
}
async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise<InsightSnapshot> {
const [projects, resources, activeAssignments] = await Promise.all([
loadInsightProjects(db.project),
loadInsightResources(db.resource),
loadInsightAssignmentLoads(db.assignment, now),
]);
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const assignmentStart = assignment.startDate < project.startDate
? project.startDate
: assignment.startDate;
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
if (assignmentEnd < assignmentStart) {
return sum;
}
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
const overrunAssignments = project.assignments.filter(
(assignment) => assignment.endDate > project.endDate
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
}
for (const resource of resources) {
const availability = resource.availability as Record<string, number> | null;
if (!availability) {
continue;
}
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
if (dailyAvailableHours <= 0) {
continue;
}
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((left, right) => {
if (left.severity !== right.severity) {
return left.severity === "critical" ? -1 : 1;
}
return left.type.localeCompare(right.type);
});
return {
anomalies,
summary: summarizeAnomalies(anomalies),
};
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const insightsRouter = createTRPCRouter({
getAnomalyDetail: controllerProcedure.query(async ({ ctx }) => {
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return {
anomalies: snapshot.anomalies,
count: snapshot.anomalies.length,
};
}),
/**
* Generate an AI-powered executive narrative for a project.
* Caches the result in the project's dynamicFields.aiNarrative to avoid
@@ -185,300 +455,16 @@ ${dataContext}`;
* No AI involved — pure data analysis.
*/
detectAnomalies: controllerProcedure.query(async ({ ctx }) => {
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
// Fetch all active projects with their demands and assignments
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
id: true,
headcount: true,
startDate: true,
endDate: true,
status: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
id: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
for (const project of projects) {
// ── Budget anomaly: spending faster than expected burn rate ──
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays; // fraction of timeline elapsed
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
const days = countBusinessDays(aStart, aEnd);
return s + a.dailyCostCents * days;
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
// ── Staffing anomaly: unfilled demands close to start ──
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
// ── Timeline anomaly: assignments extending beyond project end ──
const overrunAssignments = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
// ── Utilization anomaly: resources at extreme utilization ──
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
});
// Get all active assignments for current period
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
});
// Build resource utilization map
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const current = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, current + assignment.hoursPerDay);
}
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvailHours <= 0) continue;
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && utilizationPercent > 0) {
// Only flag under-utilization if resource has at least some bookings
// to avoid flagging bench resources
if (bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailHours.toFixed(1)}h per day).`,
});
}
}
}
// Sort: critical first, then by type
anomalies.sort((a, b) => {
if (a.severity !== b.severity) return a.severity === "critical" ? -1 : 1;
return a.type.localeCompare(b.type);
});
return anomalies;
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.anomalies;
}),
/**
* Dashboard-friendly summary: anomaly counts by category + total.
*/
getInsightsSummary: controllerProcedure.query(async ({ ctx }) => {
// Re-use the detectAnomalies logic inline (calling it directly would
// require the full context to be passed through — simpler to share code
// via the router caller pattern, but for now we duplicate the call).
const now = new Date();
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const projects = await ctx.db.project.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
});
let budgetCount = 0;
let staffingCount = 0;
let timelineCount = 0;
let criticalCount = 0;
for (const project of projects) {
// Budget check
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((s, a) => {
const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
const aEnd = a.endDate > now ? now : a.endDate;
if (aEnd < aStart) return s;
return s + a.dailyCostCents * countBusinessDays(aStart, aEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
budgetCount++;
if (actualBurnRate > expectedBurnRate * 1.5) criticalCount++;
}
}
}
// Staffing check
const upcomingDemands = project.demandRequirements.filter(
(d) => d.startDate <= twoWeeksFromNow && d.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfillPct = demand.headcount > 0 ? (demand.headcount - demand._count.assignments) / demand.headcount : 0;
if (unfillPct > 0.3) {
staffingCount++;
if (unfillPct > 0.6) criticalCount++;
}
}
// Timeline check
const overruns = project.assignments.filter(
(a) => a.endDate > project.endDate && (a.status === "ACTIVE" || a.status === "CONFIRMED"),
);
if (overruns.length > 0) timelineCount++;
}
// Utilization check
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, availability: true },
});
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const activeAssignments = await ctx.db.assignment.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { resourceId: true, hoursPerDay: true },
});
const resourceHoursMap = new Map<string, number>();
for (const a of activeAssignments) {
resourceHoursMap.set(a.resourceId, (resourceHoursMap.get(a.resourceId) ?? 0) + a.hoursPerDay);
}
let utilizationCount = 0;
for (const resource of resources) {
const avail = resource.availability as Record<string, number> | null;
if (!avail) continue;
const dailyAvail = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
if (dailyAvail <= 0) continue;
const booked = resourceHoursMap.get(resource.id) ?? 0;
const pct = Math.round((booked / dailyAvail) * 100);
if (pct > 110) {
utilizationCount++;
if (pct > 130) criticalCount++;
} else if (pct < 40 && booked > 0) {
utilizationCount++;
}
}
const total = budgetCount + staffingCount + timelineCount + utilizationCount;
return {
total,
criticalCount,
budget: budgetCount,
staffing: staffingCount,
timeline: timelineCount,
utilization: utilizationCount,
};
const snapshot = await buildInsightSnapshot(ctx.db as unknown as InsightsDbAccess);
return snapshot.summary;
}),
/**