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