Files
CapaKraken/packages/api/src/router/entitlement-procedure-support.ts
T
Hartmut 999626cf70 feat(application): extract entitlement use-cases from API router layer
Move core entitlement business logic (syncEntitlement, balance reading,
year summary, set/bulk-set) into packages/application/src/use-cases/entitlement/
using the deps-injection pattern. Audit logging stays in the router support
file; authorization check for getBalance/getBalanceDetail stays in the router
layer. The router support file becomes a thin wiring adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 20:14:35 +02:00

192 lines
6.4 KiB
TypeScript

import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
getEntitlementBalance as getEntitlementBalanceUseCase,
getEntitlementBalanceDetail as getEntitlementBalanceDetailUseCase,
getEntitlementSync,
getEntitlementYearSummary as getEntitlementYearSummaryUseCase,
getEntitlementYearSummaryDetail as getEntitlementYearSummaryDetailUseCase,
setEntitlement as setEntitlementUseCase,
bulkSetEntitlements as bulkSetEntitlementsUseCase,
type ReadEntitlementBalanceDeps,
} from "@capakraken/application";
import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import {
countVacationChargeableDaysFromSnapshot,
parseVacationSnapshotDateList,
} from "../lib/vacation-deduction-snapshot.js";
import type { TRPCContext } from "../trpc.js";
import { buildVacationPreview } from "./vacation-read-support.js";
export const EntitlementBalanceInputSchema = z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
});
export const EntitlementGetInputSchema = z.object({
resourceId: z.string(),
year: z.number().int(),
});
export const EntitlementSetInputSchema = z.object({
resourceId: z.string(),
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
});
export const EntitlementBulkSetInputSchema = z.object({
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
resourceIds: z.array(z.string()).optional(),
});
export const EntitlementYearSummaryInputSchema = z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
});
export const EntitlementYearSummaryDetailInputSchema = EntitlementYearSummaryInputSchema.extend({
resourceName: z.string().optional(),
});
function buildReadDeps(db: TRPCContext["db"]): ReadEntitlementBalanceDeps {
return {
loadResourceHolidayContext: (innerDb, resourceId, periodStart, periodEnd) =>
loadResourceHolidayContext(innerDb as TRPCContext["db"], resourceId, periodStart, periodEnd),
countCalendarDaysInPeriod,
countVacationChargeableDays,
countVacationChargeableDaysFromSnapshot,
parseVacationSnapshotDateList,
buildVacationPreview,
};
}
type EntitlementReadContext = Pick<TRPCContext, "db" | "dbUser">;
type EntitlementWriteContext = Pick<TRPCContext, "db" | "dbUser">;
export async function getEntitlementBalance(
ctx: EntitlementReadContext,
input: z.infer<typeof EntitlementBalanceInputSchema>,
) {
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
}
return getEntitlementBalanceUseCase(ctx.db, input, buildReadDeps(ctx.db));
}
export async function getEntitlementBalanceDetail(
ctx: EntitlementReadContext,
input: z.infer<typeof EntitlementBalanceInputSchema>,
) {
if (ctx.dbUser) {
const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { userId: true },
});
if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only view your own vacation balance",
});
}
}
}
return getEntitlementBalanceDetailUseCase(ctx.db, input, buildReadDeps(ctx.db));
}
export async function getEntitlement(
ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementGetInputSchema>,
) {
return getEntitlementSync(ctx.db, input, buildReadDeps(ctx.db));
}
export async function setEntitlement(
ctx: EntitlementWriteContext,
input: z.infer<typeof EntitlementSetInputSchema>,
) {
const { result, existing } = await setEntitlementUseCase(ctx.db, input);
if (existing) {
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: result.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
before: existing as unknown as Record<string, unknown>,
after: result as unknown as Record<string, unknown>,
source: "ui",
summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`,
});
} else {
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: result.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: result as unknown as Record<string, unknown>,
source: "ui",
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
});
}
return result;
}
export async function bulkSetEntitlements(
ctx: EntitlementWriteContext,
input: z.infer<typeof EntitlementBulkSetInputSchema>,
) {
const result = await bulkSetEntitlementsUseCase(ctx.db, input);
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: `bulk-${input.year}`,
entityName: `Bulk Entitlement ${input.year}`,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: { year: input.year, entitledDays: input.entitledDays, resourceCount: result.updated } as unknown as Record<string, unknown>,
source: "ui",
summary: `Bulk set entitlement to ${input.entitledDays} days for ${result.updated} resources (${input.year})`,
});
return result;
}
export async function getEntitlementYearSummary(
ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementYearSummaryInputSchema>,
) {
return getEntitlementYearSummaryUseCase(ctx.db, input, buildReadDeps(ctx.db));
}
export async function getEntitlementYearSummaryDetail(
ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementYearSummaryDetailInputSchema>,
) {
return getEntitlementYearSummaryDetailUseCase(ctx.db, input, buildReadDeps(ctx.db));
}