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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` : "";
|
||||
|
||||
|
||||
@@ -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: {} },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user