224 lines
11 KiB
TypeScript
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() } }),
|
|
);
|
|
}
|