Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrRequestService.server.ts
T

355 lines
17 KiB
TypeScript

// 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<void> {
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<typeof notify>[1],
),
).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[];
}
/** 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<Map<string, FormDefinition>> {
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<string, FormDefinition>();
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<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; formValues?: HrM05Values };
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 (!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<typeof notify>[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<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}` });
}