fix(types): replace structural DB types with Pick<PrismaClient> and remove Prisma boundary as any casts

Replace ~440 lines of hand-written structural DB client types across 7 lib files
with `Pick<PrismaClient, ...>` from @capakraken/db. This eliminates all `as any`
casts at Prisma boundaries (cron routes, allocation effects, vacation procedures)
and surfaces two pre-existing bugs:
- weekly-digest.ts: `db.allocation.count()` called non-existent model (fixed → demandRequirement)
- estimate-reminders.ts: `submittedAt` field doesn't exist on EstimateVersion (fixed → updatedAt)

Also adds root eslint.config.mjs so lint-staged can lint package files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 15:09:16 +02:00
parent 82acc56b8d
commit 9051ff73d0
17 changed files with 257 additions and 581 deletions
@@ -116,7 +116,7 @@ export async function GET(request: Request) {
.join("; ");
await createNotificationsForUsers({
db: prisma as any,
db: prisma,
userIds: adminUsers.map((u) => u.id),
type: "SYSTEM_ALERT",
title: `Auth Anomaly Detected (${report.anomalies.length} signal${report.anomalies.length > 1 ? "s" : ""})`,
@@ -23,8 +23,7 @@ export async function GET(request: Request) {
if (deny) return deny;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const alertsSent = await checkChargeabilityAlerts(prisma as any);
const alertsSent = await checkChargeabilityAlerts(prisma);
return NextResponse.json({
ok: true,
@@ -32,10 +31,10 @@ export async function GET(request: Request) {
checkedAt: new Date().toISOString(),
});
} catch (error) {
logger.error({ error, route: "/api/cron/chargeability-alerts" }, "Chargeability alert cron failed");
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
logger.error(
{ error, route: "/api/cron/chargeability-alerts" },
"Chargeability alert cron failed",
);
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}
@@ -25,8 +25,7 @@ export async function GET(request: Request) {
if (deny) return deny;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reminderCount = await checkPendingEstimateReminders(prisma as any);
const reminderCount = await checkPendingEstimateReminders(prisma);
return NextResponse.json({
ok: true,
@@ -35,9 +34,6 @@ export async function GET(request: Request) {
});
} catch (error) {
logger.error({ error, route: "/api/cron/estimate-reminders" }, "Estimate reminder cron failed");
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
);
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}
@@ -90,7 +90,7 @@ export async function GET(request: Request) {
if (adminUsers.length > 0) {
await createNotificationsForUsers({
db: prisma as any,
db: prisma,
userIds: adminUsers.map((u) => u.id),
type: "SYSTEM_ALERT",
title: "CRITICAL: Health Check Failed",
@@ -128,7 +128,7 @@ export async function GET(request: Request) {
.join(", ");
await createNotificationsForUsers({
db: prisma as any,
db: prisma,
userIds: adminUsers.map((u) => u.id),
type: "SYSTEM_ALERT",
title: `Security Audit: ${highSeverity.length} high-severity finding(s)`,
@@ -22,8 +22,7 @@ export async function GET(request: Request) {
if (deny) return deny;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await sendWeeklyDigest(prisma as any);
const result = await sendWeeklyDigest(prisma);
return NextResponse.json({
ok: true,
@@ -32,9 +31,6 @@ export async function GET(request: Request) {
});
} catch (error) {
logger.error({ error, route: "/api/cron/weekly-digest" }, "Weekly digest cron failed");
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
);
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}
+17
View File
@@ -0,0 +1,17 @@
import baseConfig from "@capakraken/eslint-config/base";
/** @type {import("eslint").Linter.FlatConfig[]} */
export default [
...baseConfig,
{
ignores: [
"apps/**",
"node_modules/**",
".claude/**",
"backups/**",
"tooling/**",
"**/dist/**",
"**/build/**",
],
},
];
+13 -110
View File
@@ -1,3 +1,4 @@
import type { PrismaClient } from "@capakraken/db";
import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing";
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
@@ -8,107 +9,10 @@ import {
} from "./resource-capacity.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
* Follows the same pattern as budget-alerts.ts.
*/
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
demandRequirement: {
findUnique: (args: {
where: { id: string };
select: {
id: true;
projectId: true;
startDate: true;
endDate: true;
hoursPerDay: true;
role: true;
roleId: true;
headcount: true;
budgetCents: true;
};
}) => Promise<{
id: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role: string | null;
roleId: string | null;
headcount: number;
budgetCents: number;
} | null>;
};
project: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
role: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
resource: {
findMany: (args: {
where: { isActive: true };
select?: {
id?: true;
displayName?: true;
eid?: true;
skills?: true;
lcrCents?: true;
chargeabilityTarget?: true;
valueScore?: true;
availability?: true;
countryId?: true;
federalState?: true;
metroCityId?: true;
country?: { select: { code: true } };
metroCity?: { select: { name: true } };
};
take?: number;
}) => Promise<Array<{
id: string;
displayName: string;
eid: string | null;
skills: unknown;
lcrCents: number;
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string | null } | null;
metroCity: { name: string | null } | null;
}>>;
};
notification: {
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
type DbClient = Pick<
PrismaClient,
"assignment" | "demandRequirement" | "project" | "role" | "resource" | "notification" | "user"
>;
const TOP_N = 3;
@@ -224,7 +128,8 @@ export async function generateAutoSuggestions(
});
const allocatedHours = resourceBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
sum +
calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
@@ -237,13 +142,12 @@ export async function generateAutoSuggestions(
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
totalAvailableHours > 0 ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) : 0;
const wouldExceedCapacity = totalAvailableHours > 0
? allocatedHours + demand.hoursPerDay > totalAvailableHours
: demand.hoursPerDay > 0;
const wouldExceedCapacity =
totalAvailableHours > 0
? allocatedHours + demand.hoursPerDay > totalAvailableHours
: demand.hoursPerDay > 0;
return {
id: resource.id,
@@ -260,8 +164,7 @@ export async function generateAutoSuggestions(
});
// 6. Rank resources using the staffing algorithm
const budgetLcrCentsPerHour =
demand.budgetCents > 0 ? demand.budgetCents : undefined;
const budgetLcrCentsPerHour = demand.budgetCents > 0 ? demand.budgetCents : undefined;
const ranked = rankResources({
requiredSkills,
+8 -53
View File
@@ -1,49 +1,8 @@
import type { PrismaClient } from "@capakraken/db";
import { listAssignmentBookings } from "@capakraken/application";
import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
project: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true; shortCode: true; budgetCents: true };
}) => Promise<{
id: string;
name: string;
shortCode: string;
budgetCents: number;
} | null>;
};
notification: {
findFirst: (args: {
where: {
entityId: string;
entityType: string;
type: string;
};
select: { id: true };
}) => Promise<{ id: string } | null>;
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
type DbClient = Pick<PrismaClient, "assignment" | "project" | "notification" | "user">;
const THRESHOLDS = [
{ percent: 100, type: "BUDGET_OVERRUN_100", label: "100%", priority: "URGENT" as const },
@@ -58,10 +17,7 @@ const THRESHOLDS = [
* Safe to call repeatedly -- duplicate notifications are prevented by checking
* whether a notification with the same entityId + type already exists.
*/
export async function checkBudgetThresholds(
db: DbClient,
projectId: string,
): Promise<void> {
export async function checkBudgetThresholds(db: DbClient, projectId: string): Promise<void> {
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, shortCode: true, budgetCents: true },
@@ -79,8 +35,7 @@ export async function checkBudgetThresholds(
let totalCostCents = 0;
for (const booking of bookings) {
const days =
(new Date(booking.endDate).getTime() -
new Date(booking.startDate).getTime()) /
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
totalCostCents += booking.dailyCostCents * days;
@@ -114,10 +69,10 @@ export async function checkBudgetThresholds(
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const formattedBudget = (project.budgetCents / 100).toLocaleString(
"de-DE",
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
);
const formattedBudget = (project.budgetCents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
await createNotificationsForUsers({
db,
+15 -72
View File
@@ -1,8 +1,5 @@
import {
deriveResourceForecast,
getMonthRange,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import { deriveResourceForecast, getMonthRange, type AssignmentSlice } from "@capakraken/engine";
import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { createNotificationsForUsers } from "./create-notification.js";
@@ -12,63 +9,7 @@ import {
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
/**
* Minimal DB client type for chargeability alerts.
* Uses structural typing so we can pass in `prisma as any` from the cron route.
*/
type DbClient = {
resource: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, unknown>;
}) => Promise<
Array<{
id: string;
displayName: string;
fte: number;
availability: unknown;
countryId: string | null;
metroCityId: string | null;
federalState: string | null;
chargeabilityTarget: number;
country: {
id?: string | null;
code: string | null;
dailyWorkingHours: number | null;
scheduleRules: unknown;
} | null;
managementLevelGroup: { targetPercentage: number | null } | null;
metroCity: { id?: string | null; name: string | null } | null;
}>
>;
};
notification: {
findFirst: (args: {
where: Record<string, unknown>;
select: { id: true };
}) => Promise<{ id: string } | null>;
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
type DbClient = Pick<PrismaClient, "resource" | "notification" | "user" | "assignment">;
/** Alert when chargeability is more than 15pp below target */
const GAP_THRESHOLD_PP = 15;
@@ -81,10 +22,7 @@ const GAP_THRESHOLD_PP = 15;
*
* Returns the number of new alerts created.
*/
export async function checkChargeabilityAlerts(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
): Promise<number> {
export async function checkChargeabilityAlerts(db: DbClient): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1;
@@ -92,7 +30,7 @@ export async function checkChargeabilityAlerts(
const monthKey = `${year}-${String(month).padStart(2, "0")}`;
// Fetch active, chg-responsible resources
const resources = await (db as DbClient).resource.findMany({
const resources = await db.resource.findMany({
where: {
isActive: true,
chgResponsibility: true,
@@ -140,7 +78,12 @@ export async function checkChargeabilityAlerts(
);
// Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
const underperformers: Array<{
resource: (typeof resources)[0];
chg: number;
target: number;
gap: number;
}> = [];
for (const resource of resources) {
const availability = resource.availability as unknown as WeekdayAvailability;
@@ -178,8 +121,8 @@ export async function checkChargeabilityAlerts(
};
});
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const targetPct =
resource.managementLevelGroup?.targetPercentage ?? resource.chargeabilityTarget / 100;
const forecast = deriveResourceForecast({
fte: resource.fte,
@@ -205,7 +148,7 @@ export async function checkChargeabilityAlerts(
if (underperformers.length === 0) return 0;
// Fetch managers to notify
const managers = await (db as DbClient).user.findMany({
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
@@ -217,7 +160,7 @@ export async function checkChargeabilityAlerts(
for (const { resource, chg, target, gap } of underperformers) {
// Duplicate check: one alert per resource per month
const entityId = `chg-alert-${resource.id}-${monthKey}`;
const existing = await (db as DbClient).notification.findFirst({
const existing = await db.notification.findFirst({
where: {
entityId,
entityType: "chargeability_alert",
+24 -23
View File
@@ -1,8 +1,8 @@
import type { Prisma, PrismaClient } from "@capakraken/db";
import { emitNotificationCreated } from "../sse/event-bus.js";
export interface CreateNotificationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: { notification: { create: (args: any) => Promise<{ id: string; userId: string }> } };
db: Pick<PrismaClient, "notification">;
userId: string;
type: string;
title: string;
@@ -31,9 +31,7 @@ export interface CreateNotificationParams {
*
* Returns the created notification's ID.
*/
export async function createNotification(
params: CreateNotificationParams,
): Promise<string> {
export async function createNotification(params: CreateNotificationParams): Promise<string> {
const {
db,
userId,
@@ -55,25 +53,28 @@ export async function createNotification(
emit = true,
} = params;
// Params use loose strings for category/priority/type so callers aren't
// coupled to the Prisma enum. Cast once at the boundary.
const data = {
userId,
type,
title,
...(body !== undefined ? { body } : {}),
...(link !== undefined ? { link } : {}),
...(entityId !== undefined ? { entityId } : {}),
...(entityType !== undefined ? { entityType } : {}),
...(category !== undefined ? { category } : {}),
...(priority !== undefined ? { priority } : {}),
...(senderId !== undefined ? { senderId } : {}),
...(channel !== undefined ? { channel } : {}),
...(taskStatus !== undefined ? { taskStatus } : {}),
...(taskAction !== undefined ? { taskAction } : {}),
...(assigneeId !== undefined ? { assigneeId } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(sourceId !== undefined ? { sourceId } : {}),
};
const notification = await db.notification.create({
data: {
userId,
type,
title,
...(body !== undefined ? { body } : {}),
...(link !== undefined ? { link } : {}),
...(entityId !== undefined ? { entityId } : {}),
...(entityType !== undefined ? { entityType } : {}),
...(category !== undefined ? { category } : {}),
...(priority !== undefined ? { priority } : {}),
...(senderId !== undefined ? { senderId } : {}),
...(channel !== undefined ? { channel } : {}),
...(taskStatus !== undefined ? { taskStatus } : {}),
...(taskAction !== undefined ? { taskAction } : {}),
...(assigneeId !== undefined ? { assigneeId } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(sourceId !== undefined ? { sourceId } : {}),
},
data: data as Prisma.NotificationUncheckedCreateInput,
});
if (emit) {
+8 -77
View File
@@ -1,71 +1,7 @@
import type { PrismaClient } from "@capakraken/db";
import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = {
estimate: {
findMany: (args: {
where: {
versions: {
some: {
status: string;
submittedAt: { lte: Date };
};
};
};
select: {
id: true;
name: true;
projectId: true;
versions: {
where: { status: string };
select: { id: true; versionNumber: true; submittedAt: true };
orderBy: { versionNumber: "desc" };
take: 1;
};
};
}) => Promise<
Array<{
id: string;
name: string;
projectId: string | null;
versions: Array<{
id: string;
versionNumber: number;
submittedAt: Date | null;
}>;
}>
>;
};
notification: {
findFirst: (args: {
where: {
entityId: string;
entityType: string;
type: string;
};
select: { id: true };
}) => Promise<{ id: string } | null>;
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
type DbClient = Pick<PrismaClient, "estimate" | "notification" | "user">;
const REMINDER_DAYS = 3;
@@ -76,9 +12,7 @@ const REMINDER_DAYS = 3;
*
* Returns the number of new reminders created.
*/
export async function checkPendingEstimateReminders(
db: DbClient,
): Promise<number> {
export async function checkPendingEstimateReminders(db: DbClient): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - REMINDER_DAYS);
@@ -87,7 +21,7 @@ export async function checkPendingEstimateReminders(
versions: {
some: {
status: "SUBMITTED",
submittedAt: { lte: cutoff },
updatedAt: { lte: cutoff },
},
},
},
@@ -97,7 +31,7 @@ export async function checkPendingEstimateReminders(
projectId: true,
versions: {
where: { status: "SUBMITTED" },
select: { id: true, versionNumber: true, submittedAt: true },
select: { id: true, versionNumber: true, updatedAt: true },
orderBy: { versionNumber: "desc" },
take: 1,
},
@@ -131,12 +65,9 @@ export async function checkPendingEstimateReminders(
if (existing) continue;
const daysPending = version.submittedAt
? Math.floor(
(Date.now() - new Date(version.submittedAt).getTime()) /
(1000 * 60 * 60 * 24),
)
: REMINDER_DAYS;
const daysPending = Math.floor(
(Date.now() - version.updatedAt.getTime()) / (1000 * 60 * 60 * 24),
);
await createNotificationsForUsers({
db,
+3 -68
View File
@@ -1,71 +1,8 @@
import type { PrismaClient } from "@capakraken/db";
import { VacationStatus } from "@capakraken/db";
import { createNotification } from "./create-notification.js";
export type DbClient = {
vacation: {
findUnique: (args: {
where: { id: string };
select: {
id: true;
resourceId: true;
startDate: true;
endDate: true;
resource: { select: { chapter: true; displayName: true } };
};
}) => Promise<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
resource: { chapter: string | null; displayName: string } | null;
} | null>;
findMany: (args: {
where: {
resource: { chapter: string };
resourceId: { not: string };
status: { in: string[] };
startDate: { lte: Date };
endDate: { gte: Date };
};
select: {
id: true;
resourceId: true;
startDate: true;
endDate: true;
resource: { select: { displayName: true } };
};
}) => Promise<
Array<{
id: string;
resourceId: string;
startDate: Date;
endDate: Date;
resource: { displayName: string } | null;
}>
>;
};
resource: {
count: (args: {
where: { chapter: string; isActive: true };
}) => Promise<number>;
};
notification: {
create: (args: {
data: {
userId: string;
type: string;
category: string;
priority: string;
title: string;
body: string;
entityId: string;
entityType: string;
link: string;
channel: string;
};
}) => Promise<{ id: string; userId: string }>;
};
};
export type DbClient = Pick<PrismaClient, "vacation" | "resource" | "notification">;
/** Threshold: warn when more than 50% of a chapter is absent on any single day */
const OVERLAP_THRESHOLD = 0.5;
@@ -178,9 +115,7 @@ export async function checkVacationConflicts(
if (worstCount > 0 && worstCount / totalInChapter > OVERLAP_THRESHOLD) {
const pct = Math.round((worstCount / totalInChapter) * 100);
const absentNames = overlapping
.map((ov) => ov.resource?.displayName ?? "Unknown")
.slice(0, 5);
const absentNames = overlapping.map((ov) => ov.resource?.displayName ?? "Unknown").slice(0, 5);
const nameList = absentNames.join(", ");
const suffix = overlapping.length > 5 ? ` and ${overlapping.length - 5} more` : "";
+27 -40
View File
@@ -1,3 +1,4 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeeklyDigestData } from "./weekly-digest-template.js";
import { sendEmail } from "./email.js";
import { buildWeeklyDigestHtml, buildWeeklyDigestText } from "./weekly-digest-template.js";
@@ -10,30 +11,10 @@ import {
} from "./resource-capacity.js";
import type { WeekdayAvailability } from "@capakraken/shared";
/** Structural DB client type — pass `prisma as any` from cron routes. */
type DbClient = {
user: {
findMany: (args: { where: Record<string, unknown>; select: Record<string, unknown> }) => Promise<Array<{ id: string; email: string; name: string | null }>>;
};
resource: {
findMany: (args: { where: Record<string, unknown>; select: Record<string, unknown>; take?: number }) => Promise<Array<{
id: string;
displayName: string;
availability: unknown;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string } | null;
metroCity: { name: string } | null;
}>>;
};
allocation: {
count: (args: { where: Record<string, unknown> }) => Promise<number>;
};
vacation: {
count: (args: { where: Record<string, unknown> }) => Promise<number>;
};
};
type DbClient = Pick<
PrismaClient,
"user" | "resource" | "demandRequirement" | "vacation" | "assignment" | "holidayCalendar"
>;
function addDays(d: Date, days: number): Date {
const next = new Date(d);
@@ -46,8 +27,7 @@ function isoDate(d: Date): string {
}
function weekLabel(start: Date, end: Date): string {
const fmt = (d: Date) =>
d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
const fmt = (d: Date) => d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
@@ -85,8 +65,7 @@ export async function sendWeeklyDigest(db: DbClient): Promise<{ sent: number; sk
// Compute utilization for each resource
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db as any,
db,
resources.map((r) => ({
id: r.id,
availability: r.availability as unknown as WeekdayAvailability,
@@ -100,11 +79,11 @@ export async function sendWeeklyDigest(db: DbClient): Promise<{ sent: number; sk
periodEnd,
);
const bookings = await listAssignmentBookings(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db as any,
{ startDate: periodStart, endDate: periodEnd, resourceIds: resources.map((r) => r.id) },
);
const bookings = await listAssignmentBookings(db, {
startDate: periodStart,
endDate: periodEnd,
resourceIds: resources.map((r) => r.id),
});
const bookingsByResource = new Map<string, typeof bookings>();
for (const b of bookings) {
if (!b.resourceId) continue;
@@ -121,7 +100,12 @@ export async function sendWeeklyDigest(db: DbClient): Promise<{ sent: number; sk
for (const r of resources) {
const availability = r.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(r.id);
const available = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context });
const available = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
const booked = (bookingsByResource.get(r.id) ?? []).reduce(
(sum, b) =>
sum +
@@ -140,21 +124,24 @@ export async function sendWeeklyDigest(db: DbClient): Promise<{ sent: number; sk
totalBooked += booked;
if (booked > available + 0.5) overbookedCount++;
if (available > 0) {
resourceUtils.push({ name: r.displayName, utilizationPct: Math.min(100, (booked / available) * 100) });
resourceUtils.push({
name: r.displayName,
utilizationPct: Math.min(100, (booked / available) * 100),
});
}
}
const teamUtilizationPct = totalAvailable > 0 ? Math.min(100, (totalBooked / totalAvailable) * 100) : 0;
const teamUtilizationPct =
totalAvailable > 0 ? Math.min(100, (totalBooked / totalAvailable) * 100) : 0;
const topResources = resourceUtils
.sort((a, b) => b.utilizationPct - a.utilizationPct)
.slice(0, 5);
// Count open demands (placeholder allocations)
const openDemandCount = await db.allocation.count({
// Count open demands (unfilled demand requirements)
const openDemandCount = await db.demandRequirement.count({
where: {
isPlaceholder: true,
status: { in: ["PROPOSED", "CONFIRMED"] as unknown as string },
endDate: { gte: today },
assignments: { none: {} },
},
});
+26 -22
View File
@@ -1,12 +1,21 @@
import type { PrismaClient } from "@capakraken/db";
import { createDemandRequirement, fillDemandRequirement } from "@capakraken/application";
import { buildTaskAction, CreateDemandRequirementSchema, FillDemandRequirementSchema } from "@capakraken/shared";
import { z } from "zod";
import type {
CreateDemandRequirementSchema,
FillDemandRequirementSchema,
} from "@capakraken/shared";
import { buildTaskAction } from "@capakraken/shared";
import type { z } from "zod";
import { checkBudgetThresholds } from "../../lib/budget-alerts.js";
import { generateAutoSuggestions } from "../../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../../lib/cache.js";
import { logger } from "../../lib/logger.js";
import { dispatchWebhooks } from "../../lib/webhook-dispatcher.js";
import { emitAllocationCreated, emitAllocationUpdated, emitNotificationCreated } from "../../sse/event-bus.js";
import {
emitAllocationCreated,
emitAllocationUpdated,
emitNotificationCreated,
} from "../../sse/event-bus.js";
export function runAllocationBackgroundEffect(
effectName: string,
@@ -27,44 +36,37 @@ export function invalidateDashboardCacheInBackground(): void {
runAllocationBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
}
export function checkBudgetThresholdsInBackground(
db: import("@capakraken/db").PrismaClient,
projectId: string,
): void {
export function checkBudgetThresholdsInBackground(db: PrismaClient, projectId: string): void {
runAllocationBackgroundEffect(
"checkBudgetThresholds",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => checkBudgetThresholds(db as any, projectId),
() => checkBudgetThresholds(db, projectId),
{ projectId },
);
}
export function dispatchAllocationWebhookInBackground(
db: import("@capakraken/db").PrismaClient,
db: PrismaClient,
event: string,
payload: Record<string, unknown>,
): void {
runAllocationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
runAllocationBackgroundEffect("dispatchWebhooks", () => dispatchWebhooks(db, event, payload), {
event,
});
}
export function generateAutoSuggestionsInBackground(
db: import("@capakraken/db").PrismaClient,
db: PrismaClient,
demandRequirementId: string,
): void {
runAllocationBackgroundEffect(
"generateAutoSuggestions",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => generateAutoSuggestions(db as any, demandRequirementId),
() => generateAutoSuggestions(db, demandRequirementId),
{ demandRequirementId },
);
}
export async function createDemandRequirementWithEffects(
db: import("@capakraken/db").PrismaClient,
db: PrismaClient,
input: z.infer<typeof CreateDemandRequirementSchema>,
) {
const demandRequirement = await db.$transaction(async (tx) => {
@@ -132,7 +134,7 @@ export async function createDemandRequirementWithEffects(
}
export async function fillDemandRequirementWithEffects(
db: import("@capakraken/db").PrismaClient,
db: PrismaClient,
input: z.infer<typeof FillDemandRequirementSchema>,
) {
const result = await fillDemandRequirement(db, input);
@@ -151,8 +153,10 @@ export async function fillDemandRequirementWithEffects(
invalidateDashboardCacheInBackground();
checkBudgetThresholdsInBackground(db, result.assignment.projectId);
if (result.updatedDemandRequirement.headcount > 0
&& result.updatedDemandRequirement.status !== "COMPLETED") {
if (
result.updatedDemandRequirement.headcount > 0 &&
result.updatedDemandRequirement.status !== "COMPLETED"
) {
generateAutoSuggestionsInBackground(db, result.updatedDemandRequirement.id);
}
@@ -43,56 +43,55 @@ const BatchCreatePublicHolidaysSchema = z.object({
});
export const vacationManagementProcedures = {
approve: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userRecord = ctx.dbUser;
const audit = makeAuditLogger(ctx.db, userRecord?.id);
approve: managerProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const userRecord = ctx.dbUser;
const audit = makeAuditLogger(ctx.db, userRecord?.id);
const result = await approveVacation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
{ id: input.id, actorUserId: userRecord?.id },
{
assertVacationApprovable,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assertVacationStillChargeable: assertVacationStillChargeable as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buildVacationApprovalWriteData: buildVacationApprovalWriteData as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkVacationConflicts: checkVacationConflicts as any,
buildApprovedVacationUpdateData,
},
const result = await approveVacation(
ctx.db,
{ id: input.id, actorUserId: userRecord?.id },
{
assertVacationApprovable,
assertVacationStillChargeable,
buildVacationApprovalWriteData,
checkVacationConflicts,
buildApprovedVacationUpdateData,
},
);
const { vacation: updated, existingStatus, warnings } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
audit({
entityType: "Vacation",
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
after: updated as unknown as Record<string, unknown>,
summary: `Approved vacation (was ${existingStatus})`,
});
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
endDate: updated.endDate.toISOString(),
});
await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
if (existingStatus === VacationStatus.PENDING) {
notifyVacationStatusInBackground(
ctx.db,
updated.id,
updated.resourceId,
VacationStatus.APPROVED,
);
}
const { vacation: updated, existingStatus, warnings } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
audit({
entityType: "Vacation",
entityId: updated.id,
entityName: `Vacation ${updated.id}`,
action: "UPDATE",
after: updated as unknown as Record<string, unknown>,
summary: `Approved vacation (was ${existingStatus})`,
});
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
id: updated.id,
resourceId: updated.resourceId,
startDate: updated.startDate.toISOString(),
endDate: updated.endDate.toISOString(),
});
await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
if (existingStatus === VacationStatus.PENDING) {
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return { ...updated, warnings };
}),
return { ...updated, warnings };
}),
reject: managerProcedure
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
@@ -105,7 +104,11 @@ export const vacationManagementProcedures = {
const { vacation: updated } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
emitVacationUpdated({
id: updated.id,
resourceId: updated.resourceId,
status: updated.status,
});
const userRecord = ctx.dbUser;
const audit = makeAuditLogger(ctx.db, userRecord?.id);
@@ -138,23 +141,28 @@ export const vacationManagementProcedures = {
const audit = makeAuditLogger(ctx.db, userRecord?.id);
const result = await batchApproveVacations(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
ctx.db,
{ ids: input.ids, actorUserId: userRecord?.id },
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assertVacationStillChargeable: assertVacationStillChargeable as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
buildVacationApprovalWriteData: buildVacationApprovalWriteData as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
checkBatchVacationConflicts: checkBatchVacationConflicts as any,
assertVacationStillChargeable,
buildVacationApprovalWriteData,
checkBatchVacationConflicts,
buildApprovedVacationUpdateData,
},
);
for (const updated of result.updatedVacations) {
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
emitVacationUpdated({
id: updated.id,
resourceId: updated.resourceId,
status: updated.status,
});
notifyVacationStatusInBackground(
ctx.db,
updated.id,
updated.resourceId,
VacationStatus.APPROVED,
);
await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id);
audit({
entityType: "Vacation",
@@ -187,7 +195,11 @@ export const vacationManagementProcedures = {
);
for (const vacation of result.vacations) {
emitVacationUpdated({ id: vacation.id, resourceId: vacation.resourceId, status: VacationStatus.REJECTED });
emitVacationUpdated({
id: vacation.id,
resourceId: vacation.resourceId,
status: VacationStatus.REJECTED,
});
notifyVacationStatusInBackground(
ctx.db,
vacation.id,
@@ -201,7 +213,10 @@ export const vacationManagementProcedures = {
entityId: vacation.id,
entityName: `Vacation ${vacation.id}`,
action: "UPDATE",
after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason } as unknown as Record<string, unknown>,
after: {
status: VacationStatus.REJECTED,
rejectionReason: input.rejectionReason,
} as unknown as Record<string, unknown>,
summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
});
@@ -236,7 +251,11 @@ export const vacationManagementProcedures = {
const { vacation: updated, existingStatus } = result;
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
emitVacationUpdated({
id: updated.id,
resourceId: updated.resourceId,
status: updated.status,
});
audit({
entityType: "Vacation",
@@ -281,7 +300,13 @@ export const vacationManagementProcedures = {
entityId: `public-holidays-${input.year}`,
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
action: "CREATE",
after: { created, holidays, resources, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
after: {
created,
holidays,
resources,
year: input.year,
federalState: input.federalState,
} as unknown as Record<string, unknown>,
summary: `Batch created ${created} public holidays for ${resources} resources (${input.year})`,
});
@@ -303,7 +328,10 @@ export const vacationManagementProcedures = {
}
if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
throw new TRPCError({
code: "FORBIDDEN",
message: "Manager role required to approve/reject",
});
}
const updated = await ctx.db.vacation.update({
@@ -316,7 +344,11 @@ export const vacationManagementProcedures = {
}),
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
emitVacationUpdated({
id: updated.id,
resourceId: updated.resourceId,
status: updated.status,
});
audit({
entityType: "Vacation",
@@ -1,14 +1,11 @@
import type { Prisma, PrismaClient, VacationStatus } from "@capakraken/db";
import type { Prisma, PrismaClient, VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
type DbClient = Pick<
PrismaClient,
"vacation" | "user" | "resource" | "notification"
>;
type DbClient = PrismaClient;
export type VacationChargeableInput = {
resourceId: string;
type: string;
type: VacationType;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
@@ -16,10 +13,7 @@ export type VacationChargeableInput = {
export type ApproveVacationDeps = {
assertVacationApprovable: (status: VacationStatus) => void;
assertVacationStillChargeable: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<void>;
assertVacationStillChargeable: (db: DbClient, vacation: VacationChargeableInput) => Promise<void>;
buildVacationApprovalWriteData: (
db: DbClient,
vacation: VacationChargeableInput,
@@ -75,11 +69,7 @@ export async function approveVacation(
isHalfDay: existing.isHalfDay,
});
const conflictResult = await deps.checkVacationConflicts(
db,
input.id,
input.actorUserId,
);
const conflictResult = await deps.checkVacationConflicts(db, input.id, input.actorUserId);
const updated = await db.vacation.update({
where: { id: input.id },
@@ -98,10 +88,7 @@ export async function approveVacation(
}
export type BatchApproveVacationDeps = {
assertVacationStillChargeable: (
db: DbClient,
vacation: VacationChargeableInput,
) => Promise<void>;
assertVacationStillChargeable: (db: DbClient, vacation: VacationChargeableInput) => Promise<void>;
buildVacationApprovalWriteData: (
db: DbClient,
vacation: VacationChargeableInput,
@@ -139,14 +126,7 @@ export async function batchApproveVacations(
input: BatchApproveVacationInput,
deps: BatchApproveVacationDeps,
): Promise<BatchApproveVacationResult> {
const vacations: Array<{
id: string;
resourceId: string;
type: string;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
}> = await db.vacation.findMany({
const vacations = await db.vacation.findMany({
where: { id: { in: input.ids }, status: "PENDING" },
select: {
id: true,
@@ -171,10 +151,7 @@ export async function batchApproveVacations(
const updatedVacations: BatchApproveVacationResult["updatedVacations"] = [];
for (const vacation of vacations) {
const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData(
db,
vacation,
);
const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData(db, vacation);
const updated = await db.vacation.update({
where: { id: vacation.id },