refactor(api): extract vacation side effects

This commit is contained in:
2026-03-31 11:02:40 +02:00
parent 269288f5df
commit e013d1af9f
2 changed files with 170 additions and 167 deletions
@@ -0,0 +1,150 @@
import { buildTaskAction } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db";
import { createNotification } from "../lib/create-notification.js";
import { sendEmail } from "../lib/email.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { emitTaskAssigned } from "../sse/event-bus.js";
import type { TRPCContext } from "../trpc.js";
type VacationDb = TRPCContext["db"];
function runVacationBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Vacation background side effect failed",
);
});
}
async function notifyVacationStatus(
db: VacationDb,
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
) {
const resource = await db.resource.findUnique({
where: { id: resourceId },
select: {
displayName: true,
user: { select: { id: true, email: true, name: true } },
},
});
if (!resource?.user) return;
const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected";
const title = `Vacation request ${statusLabel}`;
const body = rejectionReason
? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}`
: `Your vacation request has been ${statusLabel}.`;
await createNotification({
db,
userId: resource.user.id,
type: `VACATION_${newStatus}`,
title,
body,
entityId: vacationId,
entityType: "vacation",
});
if (resource.user.email) {
void sendEmail({
to: resource.user.email,
subject: `CapaKraken — ${title}`,
text: body,
});
}
}
export function notifyVacationStatusInBackground(
db: VacationDb,
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
): void {
runVacationBackgroundEffect(
"notifyVacationStatus",
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
{ vacationId, resourceId, newStatus },
);
}
export function dispatchVacationWebhookInBackground(
db: VacationDb,
event: string,
payload: Record<string, unknown>,
): void {
runVacationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
export async function createVacationApprovalTasks(args: {
db: VacationDb;
submittedByUserId: string;
vacationId: string;
resourceName: string;
vacationType: VacationType;
startDate: Date;
endDate: Date;
}): Promise<void> {
const { db, submittedByUserId, vacationId, resourceName, vacationType, startDate, endDate } = args;
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
for (const manager of managers) {
if (manager.id === submittedByUserId) continue;
const taskId = await createNotification({
db,
userId: manager.id,
category: "APPROVAL",
type: "VACATION_APPROVAL",
priority: "NORMAL",
title: `Vacation approval: ${resourceName}`,
body: `${resourceName} requests ${vacationType} from ${startStr} to ${endStr}`,
taskStatus: "OPEN",
taskAction: buildTaskAction("approve_vacation", vacationId),
entityId: vacationId,
entityType: "vacation",
link: "/vacations",
senderId: submittedByUserId,
channel: "in_app",
});
emitTaskAssigned(manager.id, taskId);
}
}
export async function completeVacationApprovalTasks(
db: VacationDb,
vacationId: string,
completedById?: string,
): Promise<void> {
await db.notification.updateMany({
where: {
taskAction: buildTaskAction("approve_vacation", vacationId),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(completedById ? { completedBy: completedById } : {}),
},
});
}
+20 -167
View File
@@ -1,16 +1,13 @@
import { UpdateVacationStatusSchema, buildTaskAction } from "@capakraken/shared"; import { UpdateVacationStatusSchema } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db"; import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../sse/event-bus.js"; import { emitVacationCreated, emitVacationUpdated } from "../sse/event-bus.js";
import { createNotification } from "../lib/create-notification.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
@@ -20,9 +17,14 @@ import {
calculateVacationDeductionSnapshot, calculateVacationDeductionSnapshot,
type VacationChargeableInput, type VacationChargeableInput,
} from "../lib/vacation-deduction-snapshot.js"; } from "../lib/vacation-deduction-snapshot.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js"; import type { TRPCContext } from "../trpc.js";
import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js";
import {
completeVacationApprovalTasks,
createVacationApprovalTasks,
dispatchVacationWebhookInBackground,
notifyVacationStatusInBackground,
} from "./vacation-side-effects.js";
async function calculateVacationEffectiveDays( async function calculateVacationEffectiveDays(
db: TRPCContext["db"], db: TRPCContext["db"],
@@ -53,47 +55,6 @@ async function assertVacationStillChargeable(
} }
} }
function runVacationBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Vacation background side effect failed",
);
});
}
function notifyVacationStatusInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
): void {
runVacationBackgroundEffect(
"notifyVacationStatus",
() => notifyVacationStatus(db, vacationId, resourceId, newStatus, rejectionReason),
{ vacationId, resourceId, newStatus },
);
}
function dispatchVacationWebhookInBackground(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
event: string,
payload: Record<string, unknown>,
): void {
runVacationBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
const CreateVacationRequestSchema = z.object({ const CreateVacationRequestSchema = z.object({
resourceId: z.string(), resourceId: z.string(),
type: z.nativeEnum(VacationType), type: z.nativeEnum(VacationType),
@@ -136,51 +97,6 @@ const CreateVacationRequestSchema = z.object({
} }
}); });
/** Send in-app notification + optional email when vacation status changes */
async function notifyVacationStatus(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
) {
// Find the resource's linked user
const resource = await db.resource.findUnique({
where: { id: resourceId },
select: {
displayName: true,
user: { select: { id: true, email: true, name: true } },
},
});
if (!resource?.user) return;
const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected";
const title = `Vacation request ${statusLabel}`;
const body = rejectionReason
? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}`
: `Your vacation request has been ${statusLabel}.`;
// In-app notification
await createNotification({
db,
userId: resource.user.id,
type: `VACATION_${newStatus}`,
title,
body,
entityId: vacationId,
entityType: "vacation",
});
// Email (non-blocking)
if (resource.user.email) {
void sendEmail({
to: resource.user.email,
subject: `CapaKraken — ${title}`,
text: body,
});
}
}
export const vacationRouter = createTRPCRouter({ export const vacationRouter = createTRPCRouter({
...vacationReadProcedures, ...vacationReadProcedures,
@@ -303,34 +219,15 @@ export const vacationRouter = createTRPCRouter({
// Create approval tasks for managers when a non-manager submits a vacation request // Create approval tasks for managers when a non-manager submits a vacation request
if (status === VacationStatus.PENDING) { if (status === VacationStatus.PENDING) {
const resourceName = vacation.resource?.displayName ?? "Unknown"; const resourceName = vacation.resource?.displayName ?? "Unknown";
const startStr = input.startDate.toISOString().slice(0, 10); await createVacationApprovalTasks({
const endStr = input.endDate.toISOString().slice(0, 10); db: ctx.db,
submittedByUserId: userRecord.id,
const managers = await ctx.db.user.findMany({ vacationId: vacation.id,
where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, resourceName,
select: { id: true }, vacationType: input.type,
startDate: input.startDate,
endDate: input.endDate,
}); });
for (const manager of managers) {
if (manager.id === userRecord.id) continue;
const taskId = await createNotification({
db: ctx.db,
userId: manager.id,
category: "APPROVAL",
type: "VACATION_APPROVAL",
priority: "NORMAL",
title: `Vacation approval: ${resourceName}`,
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
taskStatus: "OPEN",
taskAction: buildTaskAction("approve_vacation", vacation.id),
entityId: vacation.id,
entityType: "vacation",
link: "/vacations",
senderId: userRecord.id,
channel: "in_app",
});
emitTaskAssigned(manager.id, taskId);
}
} }
const directory = await getAnonymizationDirectory(ctx.db); const directory = await getAnonymizationDirectory(ctx.db);
@@ -415,18 +312,7 @@ export const vacationRouter = createTRPCRouter({
}); });
// Mark approval tasks as DONE // Mark approval tasks as DONE
await ctx.db.notification.updateMany({ await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
where: {
taskAction: buildTaskAction("approve_vacation", input.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
if (existing.status === VacationStatus.PENDING) { if (existing.status === VacationStatus.PENDING) {
notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
@@ -464,18 +350,7 @@ export const vacationRouter = createTRPCRouter({
where: { email: ctx.session.user?.email ?? "" }, where: { email: ctx.session.user?.email ?? "" },
select: { id: true }, select: { id: true },
}); });
await ctx.db.notification.updateMany({ await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id);
where: {
taskAction: buildTaskAction("approve_vacation", input.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
void createAuditEntry({ void createAuditEntry({
db: ctx.db, db: ctx.db,
@@ -575,18 +450,7 @@ export const vacationRouter = createTRPCRouter({
}); });
// Mark approval tasks as DONE // Mark approval tasks as DONE
await ctx.db.notification.updateMany({ await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id);
where: {
taskAction: buildTaskAction("approve_vacation", updated.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
} }
// Flatten all warnings into a single array // Flatten all warnings into a single array
@@ -650,18 +514,7 @@ export const vacationRouter = createTRPCRouter({
}); });
// Mark approval tasks as DONE // Mark approval tasks as DONE
await ctx.db.notification.updateMany({ await completeVacationApprovalTasks(ctx.db, v.id, userRecord?.id);
where: {
taskAction: buildTaskAction("approve_vacation", v.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
} }
return { rejected: vacations.length }; return { rejected: vacations.length };