// P17 HR-Hub workflows (AK-P17b) — generic HR requests (expense/hardware/ // training/reference) with reusable templates. // // M05 reuse (AK-P16c): a template may link a published `hr_request` form. When // it does, submitting a request also persists the payload as a real // FormSubmission (linkedEntityType "hr_request") and wires `formSubmissionId`. // The lightweight dialog fields stay as the capture surface; the linked form is // the canonical submission store. Requests without a linked form keep the // transitional `details` JSON string. Approval mirrors the leave state machine. // Every tenant access runs inside withTenantRLS(); transitions are audited // (M001) and notify the counterpart (M011, fail-open). import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation"; import { logActivity } from "@hivegrid/activity"; import { createAuditEvent } from "@hivegrid/db/audit-events.server"; import { FormDefinitionSchema, type FormDefinition } from "@hivegrid/form-widget"; import { withTenantRLS } from "~/utils/db/.server/tenantRls.server"; import { HrServiceError } from "./hrService.server"; import type { HrM05Values } from "./hrM05Forms"; import { createSystemHrSubmissionWithTx, createTemplateHrRequestSubmissionWithTx, } from "./hrM05Submission.server"; const HR_AUDIT_MODULE = "hr"; export type HrRequestKind = "expense" | "hardware" | "training" | "reference"; export type HrRequestStatus = "submitted" | "clarification" | "approved" | "rejected" | "withdrawn"; const REQUEST_KINDS: readonly HrRequestKind[] = ["expense", "hardware", "training", "reference"]; export type RequestTemplateRow = { id: string; tenantId: string; title: string; requestKind: HrRequestKind; description: string | null; defaultApproverUserId: string | null; linkedFormId: string | null; isActive: boolean; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; export type RequestRow = { id: string; tenantId: string; employeeUserId: string; templateId: string | null; requestKind: HrRequestKind; title: string; details: string | null; formSubmissionId: string | null; status: HrRequestStatus; assignedApproverUserId: string | null; clarificationNote: string | null; decidedByUserId: string | null; decidedAt: Date | null; decisionNote: string | null; withdrawnAt: Date | null; createdAt: Date; updatedAt: Date; archivedAt: Date | null; }; async function notifyQuiet(input: { tenantId: string; userId: string | null; messageKey: string; sourceKey: string }): Promise { if (!input.userId) return; await withTenantRLS(input.tenantId, (tx) => notify( { tenantId: input.tenantId, userId: input.userId, channel: "IN_APP", category: "hr", messageKey: input.messageKey, sourceKey: input.sourceKey, }, tx as unknown as Parameters[1], ), ).catch(() => undefined); } async function audit(input: { tenantId: string; actorId: string; action: string; targetId: string }): Promise { await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorId, actorType: "user", action: input.action, targetType: "HrRequest", targetId: input.targetId, sourceModule: HR_AUDIT_MODULE, outcome: "success" }); } // --- Templates ------------------------------------------------------------ export async function listTemplates(tenantId: string, opts?: { includeArchived?: boolean }): Promise { const rows = await withTenantRLS(tenantId, (tx) => tx.hrRequestTemplate.findMany({ where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) }, orderBy: { title: "asc" } }), ); return rows as RequestTemplateRow[]; } /** Published `hr_request` forms a template can link (M05 reuse, AK-P16c). */ export async function listLinkableHrRequestForms(tenantId: string): Promise<{ id: string; name: string }[]> { return withTenantRLS(tenantId, (tx) => tx.form.findMany({ where: { tenantId, subtype: "hr_request", status: "published" }, select: { id: true, name: true }, orderBy: { name: "asc" }, }), ); } export async function listRequestTemplateDefinitions( tenantId: string, ): Promise> { return withTenantRLS(tenantId, async (tx) => { const templates = await tx.hrRequestTemplate.findMany({ where: { tenantId, archivedAt: null, isActive: true, linkedFormId: { not: null }, }, select: { id: true, linkedFormId: true }, }); const formIds = templates.flatMap((template) => template.linkedFormId ? [template.linkedFormId] : [], ); if (formIds.length === 0) return new Map(); const forms = await tx.form.findMany({ where: { tenantId, id: { in: formIds }, subtype: "hr_request", status: "published", isEnabled: true, }, select: { id: true, definition: true }, }); const byFormId = new Map(); for (const form of forms) { const parsed = FormDefinitionSchema.safeParse(form.definition); if (parsed.success) byFormId.set(form.id, parsed.data); } return new Map( templates.flatMap((template) => { const definition = template.linkedFormId ? byFormId.get(template.linkedFormId) : undefined; return definition ? [[template.id, definition] as const] : []; }), ); }); } export type CreateTemplateInput = { tenantId: string; actorUserId: string; title: string; requestKind: HrRequestKind; description?: string | null; defaultApproverUserId?: string | null; linkedFormId?: string | null; isDemoData?: boolean }; export async function createTemplate(input: CreateTemplateInput): Promise { const title = input.title.trim(); if (!title) throw new HrServiceError("hr.error.templateTitleRequired"); if (!REQUEST_KINDS.includes(input.requestKind)) throw new HrServiceError("hr.error.requestKindInvalid"); const created = (await withTenantRLS(input.tenantId, (tx) => tx.hrRequestTemplate.create({ data: { tenantId: input.tenantId, title: title.slice(0, 255), requestKind: input.requestKind, description: input.description ?? null, defaultApproverUserId: input.defaultApproverUserId ?? null, linkedFormId: input.linkedFormId ?? null, isDemoData: input.isDemoData ?? false }, }), )) as RequestTemplateRow; await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REQUEST_TEMPLATE_CREATE", targetType: "HrRequestTemplate", targetId: created.id, sourceModule: HR_AUDIT_MODULE, outcome: "success" }); return created; } export type UpdateTemplateInput = Partial> & { tenantId: string; actorUserId: string; templateId: string; isActive?: boolean }; export async function updateTemplate(input: UpdateTemplateInput): Promise { await withTenantRLS(input.tenantId, async (tx) => { const existing = await tx.hrRequestTemplate.findFirst({ where: { id: input.templateId, tenantId: input.tenantId }, select: { id: true } }); if (!existing) throw new CatalogError(foundationErrors.notFound, { templateId: input.templateId }); await tx.hrRequestTemplate.update({ where: { id: input.templateId }, data: { ...(input.title !== undefined ? { title: input.title.trim().slice(0, 255) } : {}), ...(input.requestKind !== undefined ? { requestKind: input.requestKind } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.defaultApproverUserId !== undefined ? { defaultApproverUserId: input.defaultApproverUserId } : {}), ...(input.linkedFormId !== undefined ? { linkedFormId: input.linkedFormId } : {}), ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), }, }); }); await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REQUEST_TEMPLATE_UPDATE", targetType: "HrRequestTemplate", targetId: input.templateId, sourceModule: HR_AUDIT_MODULE, outcome: "success" }); } export async function archiveTemplate(input: { tenantId: string; actorUserId: string; templateId: string }): Promise { await withTenantRLS(input.tenantId, (tx) => tx.hrRequestTemplate.updateMany({ where: { id: input.templateId, tenantId: input.tenantId }, data: { archivedAt: new Date(), isActive: false } })); await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REQUEST_TEMPLATE_ARCHIVE", targetType: "HrRequestTemplate", targetId: input.templateId, sourceModule: HR_AUDIT_MODULE, outcome: "success" }); } // --- Requests ------------------------------------------------------------- export type SubmitRequestInput = { tenantId: string; employeeUserId: string; templateId?: string | null; requestKind: HrRequestKind; title: string; details?: string | null; assignedApproverUserId?: string | null; isDemoData?: boolean; formValues?: HrM05Values }; export async function submitRequest(input: SubmitRequestInput): Promise { if (!REQUEST_KINDS.includes(input.requestKind)) throw new HrServiceError("hr.error.requestKindInvalid"); const title = input.title.trim(); if (!input.templateId && !title) throw new HrServiceError("hr.error.requestTitleRequired"); const created = (await withTenantRLS(input.tenantId, async (tx) => { const template = input.templateId ? await tx.hrRequestTemplate.findFirst({ where: { id: input.templateId, tenantId: input.tenantId, archivedAt: null, isActive: true, }, select: { id: true, title: true, requestKind: true, defaultApproverUserId: true, }, }) : null; if (input.templateId && !template) { throw new CatalogError(foundationErrors.notFound, { templateId: input.templateId, }); } const effectiveTitle = template?.title ?? title; const effectiveKind = (template?.requestKind ?? input.requestKind) as HrRequestKind; const request = await tx.hrRequest.create({ data: { tenantId: input.tenantId, employeeUserId: input.employeeUserId, templateId: template?.id ?? null, requestKind: effectiveKind, title: effectiveTitle.slice(0, 255), details: input.details?.slice(0, 8000) ?? (input.formValues ? JSON.stringify(input.formValues).slice(0, 8000) : null), status: "submitted", assignedApproverUserId: input.assignedApproverUserId ?? template?.defaultApproverUserId ?? null, isDemoData: input.isDemoData ?? false, }, }); let linkedRequest = request; if (input.formValues) { const submissionId = template ? await createTemplateHrRequestSubmissionWithTx(tx, { tenantId: input.tenantId, actorUserId: input.employeeUserId, templateId: template.id, linkedEntityId: request.id, values: input.formValues, }) : await createSystemHrSubmissionWithTx(tx, { tenantId: input.tenantId, actorUserId: input.employeeUserId, process: "request", linkedEntityType: "hr_request", linkedEntityId: request.id, values: input.formValues, }); linkedRequest = await tx.hrRequest.update({ where: { id: request.id }, data: { formSubmissionId: submissionId }, }); } await logActivity( { tenantId: input.tenantId, actorUserId: input.employeeUserId, actorKind: "user", activityType: "form_submitted", subjectType: "tenant", subjectId: input.tenantId, summaryText: "activity.runtime.hr.requestSubmitted", metadata: { requestId: request.id, process: "request" }, }, { client: tx }, ); if (linkedRequest.assignedApproverUserId) { await notify( { tenantId: input.tenantId, userId: linkedRequest.assignedApproverUserId, channel: "IN_APP", category: "hr", messageKey: "hr.notify.request.submitted", sourceKey: `hr-request-submit:${request.id}`, }, tx as unknown as Parameters[1], ).catch(() => undefined); } return linkedRequest; })) as RequestRow; await audit({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_REQUEST_SUBMIT", targetId: created.id }); return created; } export async function listOwnRequests(tenantId: string, employeeUserId: string): Promise { const rows = await withTenantRLS(tenantId, (tx) => tx.hrRequest.findMany({ where: { tenantId, employeeUserId, archivedAt: null }, orderBy: { createdAt: "desc" } })); return rows as RequestRow[]; } export async function listForApprover(tenantId: string, approverUserId: string): Promise { const rows = await withTenantRLS(tenantId, (tx) => tx.hrRequest.findMany({ where: { tenantId, assignedApproverUserId: approverUserId, status: { in: ["submitted", "clarification"] }, archivedAt: null }, orderBy: { createdAt: "asc" } }), ); return rows as RequestRow[]; } async function getRequestOrThrow(tenantId: string, requestId: string): Promise { const row = await withTenantRLS(tenantId, (tx) => tx.hrRequest.findFirst({ where: { id: requestId, tenantId, archivedAt: null } })); if (!row) throw new CatalogError(foundationErrors.notFound, { requestId }); return row as RequestRow; } export async function getRequestForUser(input: { tenantId: string; requestId: string; userId: string; canApproveAny: boolean }): Promise { const row = await getRequestOrThrow(input.tenantId, input.requestId); const isOwner = row.employeeUserId === input.userId; const isApprover = row.assignedApproverUserId === input.userId || input.canApproveAny; if (!isOwner && !isApprover) throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId }); return row; } async function loadDecidable(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean }): Promise { const row = await getRequestOrThrow(input.tenantId, input.requestId); const isApprover = row.assignedApproverUserId === input.deciderUserId || input.canApproveAny; if (!isApprover) throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId }); if (row.status !== "submitted" && row.status !== "clarification") throw new HrServiceError("hr.error.requestNotDecidable"); return row; } export async function decideRequest(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean; decision: "approved" | "rejected"; decisionNote?: string | null }): Promise { const row = await loadDecidable(input); await withTenantRLS(input.tenantId, (tx) => tx.hrRequest.update({ where: { id: row.id }, data: { status: input.decision, decidedByUserId: input.deciderUserId, decidedAt: new Date(), decisionNote: input.decisionNote?.slice(0, 2000) ?? null } }), ); await audit({ tenantId: input.tenantId, actorId: input.deciderUserId, action: input.decision === "approved" ? "HR_REQUEST_APPROVE" : "HR_REQUEST_REJECT", targetId: row.id }); await notifyQuiet({ tenantId: input.tenantId, userId: row.employeeUserId, messageKey: `hr.notify.request.${input.decision}`, sourceKey: `hr-request-${input.decision}:${row.id}` }); } export async function requestClarification(input: { tenantId: string; requestId: string; deciderUserId: string; canApproveAny: boolean; question: string }): Promise { const row = await loadDecidable(input); const question = input.question.trim(); if (!question) throw new HrServiceError("hr.error.requestClarificationEmpty"); await withTenantRLS(input.tenantId, (tx) => tx.hrRequest.update({ where: { id: row.id }, data: { status: "clarification", clarificationNote: question.slice(0, 2000) } })); await audit({ tenantId: input.tenantId, actorId: input.deciderUserId, action: "HR_REQUEST_CLARIFY", targetId: row.id }); await notifyQuiet({ tenantId: input.tenantId, userId: row.employeeUserId, messageKey: "hr.notify.request.clarification", sourceKey: `hr-request-clarify:${row.id}` }); } export async function withdrawRequest(input: { tenantId: string; requestId: string; employeeUserId: string }): Promise { const row = await getRequestOrThrow(input.tenantId, input.requestId); if (row.employeeUserId !== input.employeeUserId) throw new CatalogError(foundationErrors.notFound, { requestId: input.requestId }); if (row.status !== "submitted" && row.status !== "clarification") throw new HrServiceError("hr.error.requestNotWithdrawable"); await withTenantRLS(input.tenantId, (tx) => tx.hrRequest.update({ where: { id: row.id }, data: { status: "withdrawn", withdrawnAt: new Date() } })); await audit({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_REQUEST_WITHDRAW", targetId: row.id }); await notifyQuiet({ tenantId: input.tenantId, userId: row.assignedApproverUserId, messageKey: "hr.notify.request.withdrawn", sourceKey: `hr-request-withdraw:${row.id}` }); }