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
+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: {} },
},
});