9051ff73d0
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>
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import { UpdateVacationStatusSchema } from "@capakraken/shared";
|
|
import { VacationStatus } from "@capakraken/db";
|
|
import {
|
|
approveVacation,
|
|
batchApproveVacations,
|
|
batchRejectVacations,
|
|
cancelVacation,
|
|
rejectVacation,
|
|
} from "@capakraken/application";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
|
import { makeAuditLogger } from "../lib/audit-helpers.js";
|
|
import { checkBatchVacationConflicts, checkVacationConflicts } from "../lib/vacation-conflicts.js";
|
|
import { emitVacationUpdated } from "../sse/event-bus.js";
|
|
import { adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
|
import {
|
|
assertVacationStillChargeable,
|
|
buildVacationApprovalWriteData,
|
|
} from "./vacation-chargeability.js";
|
|
import { batchCreatePublicHolidayVacations } from "./vacation-public-holidays.js";
|
|
import {
|
|
completeVacationApprovalTasks,
|
|
dispatchVacationWebhookInBackground,
|
|
notifyVacationStatusInBackground,
|
|
} from "./vacation-side-effects.js";
|
|
import {
|
|
assertVacationApprovable,
|
|
assertVacationCancelable,
|
|
assertVacationRejectable,
|
|
buildApprovedVacationUpdateData,
|
|
buildRejectedVacationUpdateData,
|
|
buildVacationStatusUpdateData,
|
|
canActorCancelVacation,
|
|
isVacationManagerRole,
|
|
} from "./vacation-management-support.js";
|
|
|
|
const BatchCreatePublicHolidaysSchema = z.object({
|
|
year: z.number().int().min(2000).max(2100),
|
|
federalState: z.string().optional(),
|
|
chapter: z.string().optional(),
|
|
replaceExisting: z.boolean().default(false),
|
|
});
|
|
|
|
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);
|
|
|
|
const result = await approveVacation(
|
|
ctx.db,
|
|
{ id: input.id, actorUserId: userRecord?.id },
|
|
{
|
|
assertVacationApprovable,
|
|
assertVacationStillChargeable,
|
|
buildVacationApprovalWriteData,
|
|
checkVacationConflicts,
|
|
buildApprovedVacationUpdateData,
|
|
},
|
|
);
|
|
|
|
const { vacation: updated, existingStatus, warnings } = result;
|
|
|
|
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: updated.id,
|
|
entityName: `Vacation ${updated.id}`,
|
|
action: "UPDATE",
|
|
after: updated as unknown as Record<string, unknown>,
|
|
summary: `Approved vacation (was ${existingStatus})`,
|
|
});
|
|
|
|
dispatchVacationWebhookInBackground(ctx.db, "vacation.approved", {
|
|
id: updated.id,
|
|
resourceId: updated.resourceId,
|
|
startDate: updated.startDate.toISOString(),
|
|
endDate: updated.endDate.toISOString(),
|
|
});
|
|
|
|
await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
|
|
|
|
if (existingStatus === VacationStatus.PENDING) {
|
|
notifyVacationStatusInBackground(
|
|
ctx.db,
|
|
updated.id,
|
|
updated.resourceId,
|
|
VacationStatus.APPROVED,
|
|
);
|
|
}
|
|
|
|
return { ...updated, warnings };
|
|
}),
|
|
|
|
reject: managerProcedure
|
|
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await rejectVacation(
|
|
ctx.db,
|
|
{ id: input.id, rejectionReason: input.rejectionReason },
|
|
{ assertVacationRejectable, buildRejectedVacationUpdateData },
|
|
);
|
|
|
|
const { vacation: updated } = result;
|
|
|
|
emitVacationUpdated({
|
|
id: updated.id,
|
|
resourceId: updated.resourceId,
|
|
status: updated.status,
|
|
});
|
|
|
|
const userRecord = ctx.dbUser;
|
|
const audit = makeAuditLogger(ctx.db, userRecord?.id);
|
|
await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: updated.id,
|
|
entityName: `Vacation ${updated.id}`,
|
|
action: "UPDATE",
|
|
after: updated as unknown as Record<string, unknown>,
|
|
summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
|
});
|
|
|
|
notifyVacationStatusInBackground(
|
|
ctx.db,
|
|
updated.id,
|
|
updated.resourceId,
|
|
VacationStatus.REJECTED,
|
|
input.rejectionReason,
|
|
);
|
|
|
|
return updated;
|
|
}),
|
|
|
|
batchApprove: managerProcedure
|
|
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userRecord = ctx.dbUser;
|
|
const audit = makeAuditLogger(ctx.db, userRecord?.id);
|
|
|
|
const result = await batchApproveVacations(
|
|
ctx.db,
|
|
{ ids: input.ids, actorUserId: userRecord?.id },
|
|
{
|
|
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,
|
|
);
|
|
await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id);
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: updated.id,
|
|
entityName: `Vacation ${updated.id}`,
|
|
action: "UPDATE",
|
|
after: updated.existingVacation as unknown as Record<string, unknown>,
|
|
summary: "Batch approved vacation",
|
|
});
|
|
}
|
|
|
|
return { approved: result.approved, warnings: result.warnings };
|
|
}),
|
|
|
|
batchReject: managerProcedure
|
|
.input(
|
|
z.object({
|
|
ids: z.array(z.string()).min(1).max(100),
|
|
rejectionReason: z.string().max(500).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userRecord = ctx.dbUser;
|
|
const audit = makeAuditLogger(ctx.db, userRecord?.id);
|
|
|
|
const result = await batchRejectVacations(
|
|
ctx.db,
|
|
{ ids: input.ids, rejectionReason: input.rejectionReason },
|
|
{ buildRejectedVacationUpdateData },
|
|
);
|
|
|
|
for (const vacation of result.vacations) {
|
|
emitVacationUpdated({
|
|
id: vacation.id,
|
|
resourceId: vacation.resourceId,
|
|
status: VacationStatus.REJECTED,
|
|
});
|
|
notifyVacationStatusInBackground(
|
|
ctx.db,
|
|
vacation.id,
|
|
vacation.resourceId,
|
|
VacationStatus.REJECTED,
|
|
input.rejectionReason,
|
|
);
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: vacation.id,
|
|
entityName: `Vacation ${vacation.id}`,
|
|
action: "UPDATE",
|
|
after: {
|
|
status: VacationStatus.REJECTED,
|
|
rejectionReason: input.rejectionReason,
|
|
} as unknown as Record<string, unknown>,
|
|
summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`,
|
|
});
|
|
|
|
await completeVacationApprovalTasks(ctx.db, vacation.id, userRecord?.id);
|
|
}
|
|
|
|
return { rejected: result.rejected };
|
|
}),
|
|
|
|
cancel: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userRecord = ctx.dbUser;
|
|
const audit = makeAuditLogger(ctx.db, userRecord?.id);
|
|
if (!userRecord) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
const result = await cancelVacation(
|
|
ctx.db,
|
|
{
|
|
id: input.id,
|
|
actorId: userRecord.id,
|
|
actorRole: userRecord.systemRole,
|
|
},
|
|
{
|
|
assertVacationCancelable,
|
|
isVacationManagerRole,
|
|
canActorCancelVacation,
|
|
},
|
|
);
|
|
|
|
const { vacation: updated, existingStatus } = result;
|
|
|
|
emitVacationUpdated({
|
|
id: updated.id,
|
|
resourceId: updated.resourceId,
|
|
status: updated.status,
|
|
});
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: updated.id,
|
|
entityName: `Vacation ${updated.id}`,
|
|
action: "UPDATE",
|
|
after: updated as unknown as Record<string, unknown>,
|
|
summary: `Cancelled vacation (was ${existingStatus})`,
|
|
});
|
|
|
|
return updated;
|
|
}),
|
|
|
|
getPendingApprovals: managerProcedure.query(async ({ ctx }) => {
|
|
return ctx.db.vacation.findMany({
|
|
where: { status: VacationStatus.PENDING },
|
|
include: {
|
|
resource: { select: { ...RESOURCE_BRIEF_SELECT, chapter: true } },
|
|
requestedBy: { select: { id: true, name: true, email: true } },
|
|
},
|
|
orderBy: { startDate: "asc" },
|
|
});
|
|
}),
|
|
|
|
batchCreatePublicHolidays: adminProcedure
|
|
.input(BatchCreatePublicHolidaysSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const adminUser = ctx.dbUser;
|
|
if (!adminUser) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
const audit = makeAuditLogger(ctx.db, adminUser.id);
|
|
|
|
const { created, holidays, resources } = await batchCreatePublicHolidayVacations(
|
|
ctx.db,
|
|
input,
|
|
adminUser.id,
|
|
);
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: `public-holidays-${input.year}`,
|
|
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
|
|
action: "CREATE",
|
|
after: {
|
|
created,
|
|
holidays,
|
|
resources,
|
|
year: input.year,
|
|
federalState: input.federalState,
|
|
} as unknown as Record<string, unknown>,
|
|
summary: `Batch created ${created} public holidays for ${resources} resources (${input.year})`,
|
|
});
|
|
|
|
return { created, holidays, resources };
|
|
}),
|
|
|
|
updateStatus: protectedProcedure
|
|
.input(UpdateVacationStatusSchema)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
|
if (!existing) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
|
}
|
|
|
|
const userRecord = ctx.dbUser;
|
|
const audit = makeAuditLogger(ctx.db, userRecord?.id);
|
|
if (!userRecord) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
}
|
|
|
|
if (input.status !== "CANCELLED" && !isVacationManagerRole(userRecord.systemRole)) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "Manager role required to approve/reject",
|
|
});
|
|
}
|
|
|
|
const updated = await ctx.db.vacation.update({
|
|
where: { id: input.id },
|
|
data: buildVacationStatusUpdateData({
|
|
status: input.status,
|
|
note: input.note,
|
|
approvedById: userRecord.id,
|
|
approvedAt: new Date(),
|
|
}),
|
|
});
|
|
|
|
emitVacationUpdated({
|
|
id: updated.id,
|
|
resourceId: updated.resourceId,
|
|
status: updated.status,
|
|
});
|
|
|
|
audit({
|
|
entityType: "Vacation",
|
|
entityId: updated.id,
|
|
entityName: `Vacation ${updated.id}`,
|
|
action: "UPDATE",
|
|
before: existing as unknown as Record<string, unknown>,
|
|
after: updated as unknown as Record<string, unknown>,
|
|
summary: `Updated vacation status to ${input.status}`,
|
|
});
|
|
|
|
return updated;
|
|
}),
|
|
};
|