355 lines
17 KiB
TypeScript
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}` });
|
|
}
|