Files
CapaKraken/packages/api/src/router/entitlement.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

339 lines
11 KiB
TypeScript

/**
* Vacation entitlement & balance router.
* Tracks annual leave quotas per resource per year.
* Balance is computed lazily: carryover from previous year is applied on first access.
*/
import { VacationType, VacationStatus } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/**
* Count calendar days between two dates (inclusive).
* Half-day vacations count as 0.5.
*/
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
if (isHalfDay) return 0.5;
const ms = endDate.getTime() - startDate.getTime();
return Math.round(ms / 86_400_000) + 1;
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
async function getOrCreateEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
// Check previous year for carryover
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
async function syncEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
});
let usedDays = 0;
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
data: { usedDays, pendingDays },
});
}
export const entitlementRouter = createTRPCRouter({
/**
* Get vacation balance for a resource in a year.
* Creates the entitlement record if it doesn't exist (with carryover).
*/
getBalance: protectedProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
}),
)
.query(async ({ ctx, input }) => {
// Ownership check: USER can only query their own balance
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",
});
}
}
}
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
// Sync from real vacation records
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}),
/**
* Get entitlement record for a resource/year (admin/manager only).
*/
get: managerProcedure
.input(z.object({ resourceId: z.string(), year: z.number().int() }))
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**
* Set entitlement for a resource/year (admin/manager only).
*/
set: managerProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
const updated = await ctx.db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: updated.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: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`,
});
return updated;
}
const created = await ctx.db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: created.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: created as unknown as Record<string, unknown>,
source: "ui",
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
});
return created;
}),
/**
* Bulk-set entitlements for multiple resources (admin only).
* Useful for setting the default entitlement for a new year.
*/
bulkSet: adminProcedure
.input(
z.object({
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
resourceIds: z.array(z.string()).optional(), // if omitted, applies to all active resources
}),
)
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const r of resources) {
await ctx.db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: r.id, year: input.year } },
create: {
resourceId: r.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
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: updated } as unknown as Record<string, unknown>,
source: "ui",
summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`,
});
return { updated };
}),
/**
* Get year summary: all resources with their balance for a given year.
* Manager/admin only.
*/
getYearSummary: managerProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
const results = await Promise.all(
resources.map(async (r) => {
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
return {
resourceId: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
return results;
}),
});