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

224 lines
11 KiB
TypeScript

// P17 HR-Hub workflows (AK-P17b) — upload-reminder domain service.
//
// HR defines reminders (upload document X on a cadence). A BullMQ worker calls
// dispatchDueReminders() on a schedule; it creates per-employee dispatch rows
// (idempotent per reminder+employee+dueAt), notifies each employee (M011), and
// escalates overdue mandatory reminders to the manager. The cadence math itself
// is a pure function (computeNextDue in @hivegrid/view-models). Every tenant
// access runs inside withTenantRLS().
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { createNotificationDelivery } from "@hivegrid/db/notifications.server";
import { computeNextDue, type HrReminderCadenceKind } from "@hivegrid/view-models";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { HrServiceError } from "./hrService.server";
const HR_AUDIT_MODULE = "hr";
export type HrAudienceKind = "all" | "team" | "location";
export type HrDocumentKind = "contract" | "certificate" | "identification" | "qualification" | "other";
export type HrReminderStatus = "active" | "paused" | "completed";
export type ReminderRow = {
id: string;
tenantId: string;
title: string;
documentKind: HrDocumentKind;
cadenceKind: HrReminderCadenceKind;
intervalMonths: number | null;
audienceKind: HrAudienceKind;
audienceRef: string | null;
mandatory: boolean;
escalateToManager: boolean;
linkedFormId: string | null;
dueAt: Date | null;
status: HrReminderStatus;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
/** Audience member resolved by the worker (decoupled from this service). */
export type ReminderAudienceMember = { userId: string; managerUserId: string | null };
export type ResolveAudience = (reminder: ReminderRow) => Promise<ReminderAudienceMember[]>;
async function notifyQuiet(input: { tenantId: string; userId: string; messageKey: string; sourceKey: string; payloadMetadata?: Record<string, string> }): Promise<void> {
await createNotificationDelivery({
tenantId: input.tenantId,
userId: input.userId,
channel: "IN_APP",
category: "hr",
messageKey: input.messageKey,
sourceKey: input.sourceKey,
payloadMetadata: input.payloadMetadata ?? null,
}).catch(() => undefined);
}
export type CreateReminderInput = {
tenantId: string;
actorUserId: string;
title: string;
documentKind?: HrDocumentKind;
cadenceKind?: HrReminderCadenceKind;
intervalMonths?: number | null;
audienceKind?: HrAudienceKind;
audienceRef?: string | null;
mandatory?: boolean;
escalateToManager?: boolean;
linkedFormId?: string | null;
dueAt?: Date | null;
isDemoData?: boolean;
};
export async function createReminder(input: CreateReminderInput): Promise<ReminderRow> {
const title = input.title.trim();
if (!title) throw new HrServiceError("hr.error.reminderTitleRequired");
if ((input.cadenceKind ?? "once") === "every_n_months" && !(input.intervalMonths && input.intervalMonths > 0)) {
throw new HrServiceError("hr.error.reminderIntervalRequired");
}
const created = (await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminder.create({
data: {
tenantId: input.tenantId,
title: title.slice(0, 255),
documentKind: input.documentKind ?? "other",
cadenceKind: input.cadenceKind ?? "once",
intervalMonths: input.intervalMonths ?? null,
audienceKind: input.audienceKind ?? "all",
audienceRef: input.audienceRef ?? null,
mandatory: input.mandatory ?? false,
escalateToManager: input.escalateToManager ?? false,
linkedFormId: input.linkedFormId ?? null,
dueAt: input.dueAt ?? null,
status: "active",
isDemoData: input.isDemoData ?? false,
},
}),
)) as ReminderRow;
await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REMINDER_CREATE", targetType: "EmployeeUploadReminder", targetId: created.id, sourceModule: HR_AUDIT_MODULE, outcome: "success" });
return created;
}
export type UpdateReminderInput = Partial<Omit<CreateReminderInput, "tenantId" | "actorUserId">> & { tenantId: string; actorUserId: string; reminderId: string };
export async function updateReminder(input: UpdateReminderInput): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.employeeUploadReminder.findFirst({ where: { id: input.reminderId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) throw new CatalogError(foundationErrors.notFound, { reminderId: input.reminderId });
await tx.employeeUploadReminder.update({
where: { id: input.reminderId },
data: {
...(input.title !== undefined ? { title: input.title.trim().slice(0, 255) } : {}),
...(input.documentKind !== undefined ? { documentKind: input.documentKind } : {}),
...(input.cadenceKind !== undefined ? { cadenceKind: input.cadenceKind } : {}),
...(input.intervalMonths !== undefined ? { intervalMonths: input.intervalMonths } : {}),
...(input.audienceKind !== undefined ? { audienceKind: input.audienceKind } : {}),
...(input.audienceRef !== undefined ? { audienceRef: input.audienceRef } : {}),
...(input.mandatory !== undefined ? { mandatory: input.mandatory } : {}),
...(input.escalateToManager !== undefined ? { escalateToManager: input.escalateToManager } : {}),
...(input.linkedFormId !== undefined ? { linkedFormId: input.linkedFormId } : {}),
...(input.dueAt !== undefined ? { dueAt: input.dueAt } : {}),
},
});
});
await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REMINDER_UPDATE", targetType: "EmployeeUploadReminder", targetId: input.reminderId, sourceModule: HR_AUDIT_MODULE, outcome: "success" });
}
export async function setReminderStatus(input: { tenantId: string; actorUserId: string; reminderId: string; status: HrReminderStatus }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.employeeUploadReminder.updateMany({ where: { id: input.reminderId, tenantId: input.tenantId }, data: { status: input.status } }));
await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REMINDER_STATUS", targetType: "EmployeeUploadReminder", targetId: input.reminderId, sourceModule: HR_AUDIT_MODULE, outcome: "success", metadata: { status: input.status } });
}
export async function archiveReminder(input: { tenantId: string; actorUserId: string; reminderId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.employeeUploadReminder.updateMany({ where: { id: input.reminderId, tenantId: input.tenantId }, data: { archivedAt: new Date(), status: "completed" } }));
await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_REMINDER_ARCHIVE", targetType: "EmployeeUploadReminder", targetId: input.reminderId, sourceModule: HR_AUDIT_MODULE, outcome: "success" });
}
export async function listReminders(tenantId: string, opts?: { includeArchived?: boolean }): Promise<ReminderRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.employeeUploadReminder.findMany({ where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) }, orderBy: { createdAt: "desc" } }),
);
return rows as ReminderRow[];
}
export type DispatchRow = {
id: string;
reminderId: string;
employeeUserId: string;
dueAt: Date;
status: "pending" | "sent" | "escalated" | "acknowledged";
sentAt: Date | null;
acknowledgedAt: Date | null;
escalatedAt: Date | null;
};
export async function listDispatches(tenantId: string, reminderId: string): Promise<DispatchRow[]> {
const rows = await withTenantRLS(tenantId, (tx) => tx.employeeUploadReminderDispatch.findMany({ where: { tenantId, reminderId }, orderBy: { dueAt: "desc" } }));
return rows as DispatchRow[];
}
/**
* Worker entry point — dispatch all reminders due at/<= `now`. Idempotent: the
* unique (reminderId, employeeUserId, dueAt) means a re-run never double-sends.
* After dispatching, the reminder's `dueAt` advances by its cadence (or the
* reminder completes for a one-off). Returns the number of dispatches created.
*/
export async function dispatchDueReminders(input: { tenantId: string; now: Date; resolveAudience: ResolveAudience }): Promise<number> {
const due = await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminder.findMany({ where: { tenantId: input.tenantId, status: "active", archivedAt: null, dueAt: { not: null, lte: input.now } } }),
);
let created = 0;
for (const reminder of due as ReminderRow[]) {
const members = await input.resolveAudience(reminder);
const dueAt = reminder.dueAt ?? input.now;
for (const member of members) {
const result = await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminderDispatch.createMany({
data: [{ tenantId: input.tenantId, reminderId: reminder.id, employeeUserId: member.userId, dueAt, status: "sent", sentAt: input.now }],
skipDuplicates: true,
}),
);
if (result.count > 0) {
created += result.count;
await notifyQuiet({ tenantId: input.tenantId, userId: member.userId, messageKey: "hr.notify.reminder.due", sourceKey: `hr-reminder:${reminder.id}:${dueAt.getTime()}:${member.userId}`, payloadMetadata: { reminderId: reminder.id } });
}
}
const next = computeNextDue({ cadenceKind: reminder.cadenceKind, intervalMonths: reminder.intervalMonths, from: dueAt });
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminder.update({ where: { id: reminder.id }, data: next ? { dueAt: next } : { dueAt: null, status: "completed" } }),
);
}
return created;
}
/**
* Escalate still-pending mandatory dispatches older than `graceMs` to the
* employee's manager. The worker resolves the manager via `resolveAudience`.
*/
export async function escalateOverdueDispatches(input: { tenantId: string; now: Date; graceMs: number; resolveManager: (employeeUserId: string) => Promise<string | null> }): Promise<number> {
const cutoff = new Date(input.now.getTime() - input.graceMs);
const stale = await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminderDispatch.findMany({
where: { tenantId: input.tenantId, status: "sent", dueAt: { lt: cutoff }, reminder: { escalateToManager: true, mandatory: true } },
}),
);
let escalated = 0;
for (const dispatch of stale as DispatchRow[]) {
const managerId = await input.resolveManager(dispatch.employeeUserId);
await withTenantRLS(input.tenantId, (tx) => tx.employeeUploadReminderDispatch.update({ where: { id: dispatch.id }, data: { status: "escalated", escalatedAt: input.now } }));
escalated += 1;
if (managerId) {
await notifyQuiet({ tenantId: input.tenantId, userId: managerId, messageKey: "hr.notify.reminder.escalated", sourceKey: `hr-reminder-escalate:${dispatch.id}`, payloadMetadata: { dispatchId: dispatch.id } });
}
}
return escalated;
}
/** Employee acknowledges (uploaded the requested document). */
export async function acknowledgeDispatch(input: { tenantId: string; dispatchId: string; userId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.employeeUploadReminderDispatch.updateMany({ where: { id: input.dispatchId, tenantId: input.tenantId, employeeUserId: input.userId }, data: { status: "acknowledged", acknowledgedAt: new Date() } }),
);
}