diff --git a/packages/api/src/router/vacation-side-effects.ts b/packages/api/src/router/vacation-side-effects.ts new file mode 100644 index 0000000..9584144 --- /dev/null +++ b/packages/api/src/router/vacation-side-effects.ts @@ -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 = {}, +): 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, +): 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 { + 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 { + await db.notification.updateMany({ + where: { + taskAction: buildTaskAction("approve_vacation", vacationId), + category: "APPROVAL", + taskStatus: "OPEN", + }, + data: { + taskStatus: "DONE", + completedAt: new Date(), + ...(completedById ? { completedBy: completedById } : {}), + }, + }); +} diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 5915ee0..325bf65 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -1,16 +1,13 @@ -import { UpdateVacationStatusSchema, buildTaskAction } from "@capakraken/shared"; +import { UpdateVacationStatusSchema } from "@capakraken/shared"; import { VacationStatus, VacationType } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; -import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../sse/event-bus.js"; -import { createNotification } from "../lib/create-notification.js"; +import { emitVacationCreated, emitVacationUpdated } from "../sse/event-bus.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; -import { sendEmail } from "../lib/email.js"; import { getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; -import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { createAuditEntry } from "../lib/audit.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; @@ -20,9 +17,14 @@ import { calculateVacationDeductionSnapshot, type VacationChargeableInput, } from "../lib/vacation-deduction-snapshot.js"; -import { logger } from "../lib/logger.js"; import type { TRPCContext } from "../trpc.js"; import { anonymizeVacationRecord, isSameUtcDay, vacationReadProcedures } from "./vacation-read.js"; +import { + completeVacationApprovalTasks, + createVacationApprovalTasks, + dispatchVacationWebhookInBackground, + notifyVacationStatusInBackground, +} from "./vacation-side-effects.js"; async function calculateVacationEffectiveDays( db: TRPCContext["db"], @@ -53,47 +55,6 @@ async function assertVacationStillChargeable( } } -function runVacationBackgroundEffect( - effectName: string, - execute: () => unknown, - metadata: Record = {}, -): void { - void Promise.resolve() - .then(execute) - .catch((error) => { - logger.error( - { err: error, effectName, ...metadata }, - "Vacation background side effect failed", - ); - }); -} - -function notifyVacationStatusInBackground( - db: Parameters[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[0]>[0]["ctx"]["db"], - event: string, - payload: Record, -): void { - runVacationBackgroundEffect( - "dispatchWebhooks", - () => dispatchWebhooks(db, event, payload), - { event }, - ); -} - const CreateVacationRequestSchema = z.object({ resourceId: z.string(), 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[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({ ...vacationReadProcedures, @@ -303,34 +219,15 @@ export const vacationRouter = createTRPCRouter({ // Create approval tasks for managers when a non-manager submits a vacation request if (status === VacationStatus.PENDING) { const resourceName = vacation.resource?.displayName ?? "Unknown"; - const startStr = input.startDate.toISOString().slice(0, 10); - const endStr = input.endDate.toISOString().slice(0, 10); - - const managers = await ctx.db.user.findMany({ - where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, - select: { id: true }, + await createVacationApprovalTasks({ + db: ctx.db, + submittedByUserId: userRecord.id, + vacationId: vacation.id, + resourceName, + 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); @@ -415,18 +312,7 @@ export const vacationRouter = createTRPCRouter({ }); // Mark approval tasks as DONE - await ctx.db.notification.updateMany({ - where: { - taskAction: buildTaskAction("approve_vacation", input.id), - category: "APPROVAL", - taskStatus: "OPEN", - }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - ...(userRecord?.id ? { completedBy: userRecord.id } : {}), - }, - }); + await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); if (existing.status === VacationStatus.PENDING) { notifyVacationStatusInBackground(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); @@ -464,18 +350,7 @@ export const vacationRouter = createTRPCRouter({ where: { email: ctx.session.user?.email ?? "" }, select: { id: true }, }); - await ctx.db.notification.updateMany({ - where: { - taskAction: buildTaskAction("approve_vacation", input.id), - category: "APPROVAL", - taskStatus: "OPEN", - }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - ...(userRecord?.id ? { completedBy: userRecord.id } : {}), - }, - }); + await completeVacationApprovalTasks(ctx.db, input.id, userRecord?.id); void createAuditEntry({ db: ctx.db, @@ -575,18 +450,7 @@ export const vacationRouter = createTRPCRouter({ }); // Mark approval tasks as DONE - await ctx.db.notification.updateMany({ - where: { - taskAction: buildTaskAction("approve_vacation", updated.id), - category: "APPROVAL", - taskStatus: "OPEN", - }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - ...(userRecord?.id ? { completedBy: userRecord.id } : {}), - }, - }); + await completeVacationApprovalTasks(ctx.db, updated.id, userRecord?.id); } // Flatten all warnings into a single array @@ -650,18 +514,7 @@ export const vacationRouter = createTRPCRouter({ }); // Mark approval tasks as DONE - await ctx.db.notification.updateMany({ - where: { - taskAction: buildTaskAction("approve_vacation", v.id), - category: "APPROVAL", - taskStatus: "OPEN", - }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - ...(userRecord?.id ? { completedBy: userRecord.id } : {}), - }, - }); + await completeVacationApprovalTasks(ctx.db, v.id, userRecord?.id); } return { rejected: vacations.length };