diff --git a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts index 12798b9..6437a15 100644 --- a/apps/web/src/app/api/cron/auth-anomaly-check/route.ts +++ b/apps/web/src/app/api/cron/auth-anomaly-check/route.ts @@ -116,7 +116,7 @@ export async function GET(request: Request) { .join("; "); await createNotificationsForUsers({ - db: prisma as any, + db: prisma, userIds: adminUsers.map((u) => u.id), type: "SYSTEM_ALERT", title: `Auth Anomaly Detected (${report.anomalies.length} signal${report.anomalies.length > 1 ? "s" : ""})`, diff --git a/apps/web/src/app/api/cron/chargeability-alerts/route.ts b/apps/web/src/app/api/cron/chargeability-alerts/route.ts index fb37ec5..56c7bb1 100644 --- a/apps/web/src/app/api/cron/chargeability-alerts/route.ts +++ b/apps/web/src/app/api/cron/chargeability-alerts/route.ts @@ -23,8 +23,7 @@ export async function GET(request: Request) { if (deny) return deny; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const alertsSent = await checkChargeabilityAlerts(prisma as any); + const alertsSent = await checkChargeabilityAlerts(prisma); return NextResponse.json({ ok: true, @@ -32,10 +31,10 @@ export async function GET(request: Request) { checkedAt: new Date().toISOString(), }); } catch (error) { - logger.error({ error, route: "/api/cron/chargeability-alerts" }, "Chargeability alert cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, + logger.error( + { error, route: "/api/cron/chargeability-alerts" }, + "Chargeability alert cron failed", ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/cron/estimate-reminders/route.ts b/apps/web/src/app/api/cron/estimate-reminders/route.ts index 71873a7..228a039 100644 --- a/apps/web/src/app/api/cron/estimate-reminders/route.ts +++ b/apps/web/src/app/api/cron/estimate-reminders/route.ts @@ -25,8 +25,7 @@ export async function GET(request: Request) { if (deny) return deny; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reminderCount = await checkPendingEstimateReminders(prisma as any); + const reminderCount = await checkPendingEstimateReminders(prisma); return NextResponse.json({ ok: true, @@ -35,9 +34,6 @@ export async function GET(request: Request) { }); } catch (error) { logger.error({ error, route: "/api/cron/estimate-reminders" }, "Estimate reminder cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, - ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/cron/health-check/route.ts b/apps/web/src/app/api/cron/health-check/route.ts index a2d2a8f..c553c0d 100644 --- a/apps/web/src/app/api/cron/health-check/route.ts +++ b/apps/web/src/app/api/cron/health-check/route.ts @@ -90,7 +90,7 @@ export async function GET(request: Request) { if (adminUsers.length > 0) { await createNotificationsForUsers({ - db: prisma as any, + db: prisma, userIds: adminUsers.map((u) => u.id), type: "SYSTEM_ALERT", title: "CRITICAL: Health Check Failed", diff --git a/apps/web/src/app/api/cron/security-audit/route.ts b/apps/web/src/app/api/cron/security-audit/route.ts index 7939af7..15f2966 100644 --- a/apps/web/src/app/api/cron/security-audit/route.ts +++ b/apps/web/src/app/api/cron/security-audit/route.ts @@ -128,7 +128,7 @@ export async function GET(request: Request) { .join(", "); await createNotificationsForUsers({ - db: prisma as any, + db: prisma, userIds: adminUsers.map((u) => u.id), type: "SYSTEM_ALERT", title: `Security Audit: ${highSeverity.length} high-severity finding(s)`, diff --git a/apps/web/src/app/api/cron/weekly-digest/route.ts b/apps/web/src/app/api/cron/weekly-digest/route.ts index 27cfe96..f1a9b28 100644 --- a/apps/web/src/app/api/cron/weekly-digest/route.ts +++ b/apps/web/src/app/api/cron/weekly-digest/route.ts @@ -22,8 +22,7 @@ export async function GET(request: Request) { if (deny) return deny; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await sendWeeklyDigest(prisma as any); + const result = await sendWeeklyDigest(prisma); return NextResponse.json({ ok: true, @@ -32,9 +31,6 @@ export async function GET(request: Request) { }); } catch (error) { logger.error({ error, route: "/api/cron/weekly-digest" }, "Weekly digest cron failed"); - return NextResponse.json( - { ok: false, error: "Internal error" }, - { status: 500 }, - ); + return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } } diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..dc1a03a --- /dev/null +++ b/eslint.config.mjs @@ -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/**", + ], + }, +]; diff --git a/packages/api/src/lib/auto-staffing.ts b/packages/api/src/lib/auto-staffing.ts index 82c664c..a16e332 100644 --- a/packages/api/src/lib/auto-staffing.ts +++ b/packages/api/src/lib/auto-staffing.ts @@ -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[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>; - }; - 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>; - }; -}; +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, diff --git a/packages/api/src/lib/budget-alerts.ts b/packages/api/src/lib/budget-alerts.ts index b02b0e0..45636ef 100644 --- a/packages/api/src/lib/budget-alerts.ts +++ b/packages/api/src/lib/budget-alerts.ts @@ -1,49 +1,8 @@ +import type { PrismaClient } from "@capakraken/db"; import { listAssignmentBookings } from "@capakraken/application"; import { createNotificationsForUsers } from "./create-notification.js"; -type DbClient = Parameters[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>; - }; -}; +type DbClient = Pick; 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 { +export async function checkBudgetThresholds(db: DbClient, projectId: string): Promise { 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, diff --git a/packages/api/src/lib/chargeability-alerts.ts b/packages/api/src/lib/chargeability-alerts.ts index 89e1529..30e24b4 100644 --- a/packages/api/src/lib/chargeability-alerts.ts +++ b/packages/api/src/lib/chargeability-alerts.ts @@ -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; - select: Record; - }) => 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; - 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>; - }; -}; +type DbClient = Pick; /** 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 { +export async function checkChargeabilityAlerts(db: DbClient): Promise { 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", diff --git a/packages/api/src/lib/create-notification.ts b/packages/api/src/lib/create-notification.ts index df336c0..39497a9 100644 --- a/packages/api/src/lib/create-notification.ts +++ b/packages/api/src/lib/create-notification.ts @@ -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; 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 { +export async function createNotification(params: CreateNotificationParams): Promise { 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) { diff --git a/packages/api/src/lib/estimate-reminders.ts b/packages/api/src/lib/estimate-reminders.ts index 50ef498..c0acff6 100644 --- a/packages/api/src/lib/estimate-reminders.ts +++ b/packages/api/src/lib/estimate-reminders.ts @@ -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>; - }; -}; +type DbClient = Pick; 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 { +export async function checkPendingEstimateReminders(db: DbClient): Promise { 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, diff --git a/packages/api/src/lib/vacation-conflicts.ts b/packages/api/src/lib/vacation-conflicts.ts index 241fbd5..d34ae8c 100644 --- a/packages/api/src/lib/vacation-conflicts.ts +++ b/packages/api/src/lib/vacation-conflicts.ts @@ -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; - }; - 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; /** 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` : ""; diff --git a/packages/api/src/lib/weekly-digest.ts b/packages/api/src/lib/weekly-digest.ts index f580975..57b1ff4 100644 --- a/packages/api/src/lib/weekly-digest.ts +++ b/packages/api/src/lib/weekly-digest.ts @@ -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; select: Record }) => Promise>; - }; - resource: { - findMany: (args: { where: Record; select: Record; take?: number }) => Promise>; - }; - allocation: { - count: (args: { where: Record }) => Promise; - }; - vacation: { - count: (args: { where: Record }) => Promise; - }; -}; +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(); 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: {} }, }, }); diff --git a/packages/api/src/router/allocation/effects.ts b/packages/api/src/router/allocation/effects.ts index 4adbe3b..ad98c4c 100644 --- a/packages/api/src/router/allocation/effects.ts +++ b/packages/api/src/router/allocation/effects.ts @@ -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, ): 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, ) { 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, ) { 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); } diff --git a/packages/api/src/router/vacation-management-procedures.ts b/packages/api/src/router/vacation-management-procedures.ts index 87b64a8..347ce4f 100644 --- a/packages/api/src/router/vacation-management-procedures.ts +++ b/packages/api/src/router/vacation-management-procedures.ts @@ -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, + 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, - 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, + after: { + status: VacationStatus.REJECTED, + rejectionReason: input.rejectionReason, + } as unknown as Record, 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, + after: { + created, + holidays, + resources, + year: input.year, + federalState: input.federalState, + } as unknown as Record, 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", diff --git a/packages/application/src/use-cases/vacation/approve-vacation.ts b/packages/application/src/use-cases/vacation/approve-vacation.ts index b491519..4785ed8 100644 --- a/packages/application/src/use-cases/vacation/approve-vacation.ts +++ b/packages/application/src/use-cases/vacation/approve-vacation.ts @@ -1,14 +1,11 @@ -import type { Prisma, PrismaClient, VacationStatus } from "@capakraken/db"; +import type { Prisma, PrismaClient, VacationStatus, VacationType } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; -type DbClient = Pick< - PrismaClient, - "vacation" | "user" | "resource" | "notification" ->; +type DbClient = PrismaClient; export type VacationChargeableInput = { resourceId: string; - type: string; + type: VacationType; startDate: Date; endDate: Date; isHalfDay: boolean; @@ -16,10 +13,7 @@ export type VacationChargeableInput = { export type ApproveVacationDeps = { assertVacationApprovable: (status: VacationStatus) => void; - assertVacationStillChargeable: ( - db: DbClient, - vacation: VacationChargeableInput, - ) => Promise; + assertVacationStillChargeable: (db: DbClient, vacation: VacationChargeableInput) => Promise; buildVacationApprovalWriteData: ( db: DbClient, vacation: VacationChargeableInput, @@ -75,11 +69,7 @@ export async function approveVacation( isHalfDay: existing.isHalfDay, }); - const conflictResult = await deps.checkVacationConflicts( - db, - input.id, - input.actorUserId, - ); + const conflictResult = await deps.checkVacationConflicts(db, input.id, input.actorUserId); const updated = await db.vacation.update({ where: { id: input.id }, @@ -98,10 +88,7 @@ export async function approveVacation( } export type BatchApproveVacationDeps = { - assertVacationStillChargeable: ( - db: DbClient, - vacation: VacationChargeableInput, - ) => Promise; + assertVacationStillChargeable: (db: DbClient, vacation: VacationChargeableInput) => Promise; buildVacationApprovalWriteData: ( db: DbClient, vacation: VacationChargeableInput, @@ -139,14 +126,7 @@ export async function batchApproveVacations( input: BatchApproveVacationInput, deps: BatchApproveVacationDeps, ): Promise { - const vacations: Array<{ - id: string; - resourceId: string; - type: string; - startDate: Date; - endDate: Date; - isHalfDay: boolean; - }> = await db.vacation.findMany({ + const vacations = await db.vacation.findMany({ where: { id: { in: input.ids }, status: "PENDING" }, select: { id: true, @@ -171,10 +151,7 @@ export async function batchApproveVacations( const updatedVacations: BatchApproveVacationResult["updatedVacations"] = []; for (const vacation of vacations) { - const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData( - db, - vacation, - ); + const deductionSnapshotWriteData = await deps.buildVacationApprovalWriteData(db, vacation); const updated = await db.vacation.update({ where: { id: vacation.id },