Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrRequestService.server.ts
T
2026-07-08 16:15:37 +02:00

205 lines
12 KiB
TypeScript

// P17 HR-Hub workflows (AK-P17b) — generic HR requests (expense/hardware/
// training/reference) with reusable templates.
//
// Until the M05 form renderer (AK-P16c) lands, the request payload is captured
// as a JSON string in `details` (transitional form) and `formSubmissionId` is
// wired in later. 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 } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { createNotificationDelivery } from "@hivegrid/db/notifications.server";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { HrServiceError } from "./hrService.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<void> {
if (!input.userId) return;
await createNotificationDelivery({ tenantId: input.tenantId, userId: input.userId, channel: "IN_APP", category: "hr", messageKey: input.messageKey, sourceKey: input.sourceKey }).catch(() => undefined);
}
async function audit(input: { tenantId: string; actorId: string; action: string; targetId: string }): Promise<void> {
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<RequestTemplateRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.hrRequestTemplate.findMany({ where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) }, orderBy: { title: "asc" } }),
);
return rows as RequestTemplateRow[];
}
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<RequestTemplateRow> {
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<Omit<CreateTemplateInput, "tenantId" | "actorUserId">> & { tenantId: string; actorUserId: string; templateId: string; isActive?: boolean };
export async function updateTemplate(input: UpdateTemplateInput): Promise<void> {
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<void> {
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 };
export async function submitRequest(input: SubmitRequestInput): Promise<RequestRow> {
if (!REQUEST_KINDS.includes(input.requestKind)) throw new HrServiceError("hr.error.requestKindInvalid");
const title = input.title.trim();
if (!title) throw new HrServiceError("hr.error.requestTitleRequired");
const created = (await withTenantRLS(input.tenantId, (tx) =>
tx.hrRequest.create({
data: {
tenantId: input.tenantId,
employeeUserId: input.employeeUserId,
templateId: input.templateId ?? null,
requestKind: input.requestKind,
title: title.slice(0, 255),
details: input.details?.slice(0, 8000) ?? null,
status: "submitted",
assignedApproverUserId: input.assignedApproverUserId ?? null,
isDemoData: input.isDemoData ?? false,
},
}),
)) as RequestRow;
await audit({ tenantId: input.tenantId, actorId: input.employeeUserId, action: "HR_REQUEST_SUBMIT", targetId: created.id });
await notifyQuiet({ tenantId: input.tenantId, userId: created.assignedApproverUserId, messageKey: "hr.notify.request.submitted", sourceKey: `hr-request-submit:${created.id}` });
return created;
}
export async function listOwnRequests(tenantId: string, employeeUserId: string): Promise<RequestRow[]> {
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<RequestRow[]> {
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<RequestRow> {
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<RequestRow> {
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<RequestRow> {
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<void> {
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<void> {
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<void> {
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}` });
}