690 lines
25 KiB
TypeScript
690 lines
25 KiB
TypeScript
// P17 HR-Hub core (AK-P17a) §S4 — domain service for employee master data,
|
|
// locations and personnel documents.
|
|
//
|
|
// Pure-ish server logic: builds/queries HR rows (always tenant-scoped), enforces
|
|
// the MA vs HR-admin split (self can edit a whitelist of own fields; HR-admin
|
|
// edits the rest), enforces visibility (404, not 403), and routes the sensitive
|
|
// emergency contact through the hrFieldCrypto seam (AD-8, deferred). NO React/UI
|
|
// imports. Routes call these from their loaders/actions and build the View-Model
|
|
// from the returned rows.
|
|
//
|
|
// Tenant isolation is enforced twofold: every query carries `where: { tenantId }`
|
|
// (app-level scoping) plus the RLS policies from prisma/rls/hr-isolation.sql
|
|
// (once R-P2b's `SET LOCAL app.tenant_id` is live). RLS-ready: every tenant
|
|
// access runs inside withTenantRLS(); internal composition reuses the active
|
|
// transaction via *WithTx helpers and never nests withTenantRLS(). The only
|
|
// unwrapped access is the GLOBAL User lookup (resolveUserLabels): User is not
|
|
// tenant-scoped, so it stays on the db singleton.
|
|
|
|
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
|
|
import { logActivity } from "@hivegrid/activity";
|
|
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import { getHrFieldCrypto, type HrFieldCrypto } from "./hrFieldCrypto.server";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
|
|
const HR_AUDIT_MODULE = "hr";
|
|
|
|
/**
|
|
* Domain error carrying an i18n message key (M002 Klartext). Routes map
|
|
* `messageKey` to a catalog error / VM error branch — never a bare HTTP code.
|
|
*/
|
|
export class HrServiceError extends Error {
|
|
readonly messageKey: string;
|
|
constructor(messageKey: string) {
|
|
super(messageKey);
|
|
this.name = "HrServiceError";
|
|
this.messageKey = messageKey;
|
|
}
|
|
}
|
|
|
|
export type HrDocumentKind = "contract" | "certificate" | "identification" | "qualification" | "other";
|
|
export type HrDocumentVisibility = "employee" | "hr_only";
|
|
export type HrResourceKind = "link" | "file";
|
|
|
|
const MAX_NAME_LENGTH = 255;
|
|
|
|
/** The plaintext emergency contact triple (decrypted only on explicit reveal). */
|
|
export type EmergencyContact = {
|
|
name: string;
|
|
phone: string;
|
|
relation: string;
|
|
};
|
|
|
|
// --- Profile rows ---------------------------------------------------------
|
|
|
|
export type HrEmployeeProfileRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
userId: string;
|
|
personalNumber: string | null;
|
|
hireDate: Date | null;
|
|
position: string | null;
|
|
team: string | null;
|
|
locationId: string | null;
|
|
managerUserId: string | null;
|
|
emergencyContactCiphertext: string | null;
|
|
isDemoData: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
/** Fields an employee may edit on their OWN profile (MA self-service). Team,
|
|
* personal number, hire date, manager and location are HR-managed assignments. */
|
|
const SELF_EDITABLE_FIELDS = ["position"] as const;
|
|
export type SelfEditableProfileInput = Partial<Pick<HrEmployeeProfileRow, (typeof SELF_EDITABLE_FIELDS)[number]>>;
|
|
|
|
/** Load a single employee profile (tenant-scoped), or null. */
|
|
export async function getProfileByUser(tenantId: string, userId: string): Promise<HrEmployeeProfileRow | null> {
|
|
const row = await withTenantRLS(tenantId, (tx) => tx.hrEmployeeProfile.findFirst({ where: { tenantId, userId } }));
|
|
return (row as HrEmployeeProfileRow | null) ?? null;
|
|
}
|
|
|
|
export type DirectoryEntryRow = {
|
|
userId: string;
|
|
position: string | null;
|
|
team: string | null;
|
|
locationId: string | null;
|
|
managerUserId: string | null;
|
|
};
|
|
|
|
/** List the employee directory (tenant-scoped). Sensitive ciphertext is never
|
|
* selected here — the directory carries only non-sensitive display fields. */
|
|
export async function listDirectory(tenantId: string): Promise<DirectoryEntryRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.findMany({
|
|
where: { tenantId },
|
|
select: { userId: true, position: true, team: true, locationId: true, managerUserId: true },
|
|
orderBy: { team: "asc" },
|
|
}),
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Directory visibility for one viewer. HR readers see the full tenant;
|
|
* employees see only themselves and their direct reports. This is deliberately
|
|
* a server-side predicate so query-string filters cannot widen the audience.
|
|
*/
|
|
export async function listDirectoryForViewer(input: {
|
|
tenantId: string;
|
|
viewerUserId: string;
|
|
canReadAll: boolean;
|
|
}): Promise<DirectoryEntryRow[]> {
|
|
const rows = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
...(input.canReadAll
|
|
? {}
|
|
: {
|
|
OR: [{ userId: input.viewerUserId }, { managerUserId: input.viewerUserId }],
|
|
}),
|
|
},
|
|
select: { userId: true, position: true, team: true, locationId: true, managerUserId: true },
|
|
orderBy: [{ team: "asc" }, { userId: "asc" }],
|
|
}),
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
/** Fail closed when an employee target is outside self/direct-team/HR scope. */
|
|
export async function requireEmployeeObjectAccess(input: {
|
|
tenantId: string;
|
|
viewerUserId: string;
|
|
employeeUserId: string;
|
|
canReadAll: boolean;
|
|
}): Promise<{ isSelf: boolean; isDirectReport: boolean }> {
|
|
const isSelf = input.viewerUserId === input.employeeUserId;
|
|
if (isSelf) {
|
|
return { isSelf: true, isDirectReport: false };
|
|
}
|
|
const row = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.findFirst({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
userId: input.employeeUserId,
|
|
...(input.canReadAll ? {} : { managerUserId: input.viewerUserId }),
|
|
},
|
|
select: { userId: true, managerUserId: true },
|
|
}),
|
|
);
|
|
if (!row) {
|
|
throw new CatalogError(foundationErrors.notFound, { userId: input.employeeUserId });
|
|
}
|
|
return { isSelf, isDirectReport: row.managerUserId === input.viewerUserId };
|
|
}
|
|
|
|
/** Upsert the caller's OWN profile, restricted to self-editable fields. */
|
|
export async function updateOwnProfile(input: { tenantId: string; userId: string; data: SelfEditableProfileInput }): Promise<void> {
|
|
const data: SelfEditableProfileInput = {};
|
|
for (const field of SELF_EDITABLE_FIELDS) {
|
|
if (input.data[field] !== undefined) {
|
|
data[field] = input.data[field]?.toString().slice(0, MAX_NAME_LENGTH) ?? null;
|
|
}
|
|
}
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.upsert({
|
|
where: { tenantId_userId: { tenantId: input.tenantId, userId: input.userId } },
|
|
update: data,
|
|
create: { tenantId: input.tenantId, userId: input.userId, ...data },
|
|
}),
|
|
);
|
|
}
|
|
|
|
export type AdminProfileInput = {
|
|
tenantId: string;
|
|
userId: string;
|
|
personalNumber?: string | null;
|
|
hireDate?: Date | null;
|
|
position?: string | null;
|
|
team?: string | null;
|
|
locationId?: string | null;
|
|
managerUserId?: string | null;
|
|
isDemoData?: boolean;
|
|
};
|
|
|
|
/** HR-admin upsert of any employee's managed profile fields. */
|
|
export async function adminUpsertProfile(input: AdminProfileInput): Promise<void> {
|
|
const data = {
|
|
...(input.personalNumber !== undefined ? { personalNumber: input.personalNumber } : {}),
|
|
...(input.hireDate !== undefined ? { hireDate: input.hireDate } : {}),
|
|
...(input.position !== undefined ? { position: input.position } : {}),
|
|
...(input.team !== undefined ? { team: input.team } : {}),
|
|
...(input.locationId !== undefined ? { locationId: input.locationId } : {}),
|
|
...(input.managerUserId !== undefined ? { managerUserId: input.managerUserId } : {}),
|
|
...(input.isDemoData !== undefined ? { isDemoData: input.isDemoData } : {}),
|
|
};
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.upsert({
|
|
where: { tenantId_userId: { tenantId: input.tenantId, userId: input.userId } },
|
|
update: data,
|
|
create: { tenantId: input.tenantId, userId: input.userId, ...data },
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Store an employee's emergency contact, ENVELOPE-ENCRYPTED (AD-8). Goes through
|
|
* the hrFieldCrypto seam — until the AD-8 envelope ticket lands the production
|
|
* binding throws HrCryptoNotProvisionedError (M002 Klartext). Tests inject a
|
|
* working `crypto`.
|
|
*/
|
|
export async function setEmergencyContact(
|
|
input: { tenantId: string; userId: string; contact: EmergencyContact },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto(),
|
|
): Promise<void> {
|
|
const ciphertext = await crypto.encrypt(input.tenantId, JSON.stringify(input.contact));
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeProfile.upsert({
|
|
where: { tenantId_userId: { tenantId: input.tenantId, userId: input.userId } },
|
|
update: { emergencyContactCiphertext: ciphertext },
|
|
create: { tenantId: input.tenantId, userId: input.userId, emergencyContactCiphertext: ciphertext },
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reveal (decrypt) an employee's emergency contact ON DEMAND. Writes a
|
|
* pii/gdpr audit event BEFORE returning the plaintext. The caller must already
|
|
* have established that `revealerUserId` is the employee themselves or holds
|
|
* hr.read_all (enforced in the route).
|
|
*/
|
|
export async function revealEmergencyContact(
|
|
input: { tenantId: string; userId: string; revealerUserId: string },
|
|
crypto: HrFieldCrypto = getHrFieldCrypto(),
|
|
): Promise<EmergencyContact | null> {
|
|
const profile = await getProfileByUser(input.tenantId, input.userId);
|
|
if (!profile) {
|
|
throw new CatalogError(foundationErrors.notFound, { userId: input.userId });
|
|
}
|
|
if (!profile.emergencyContactCiphertext) {
|
|
return null;
|
|
}
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.revealerUserId,
|
|
actorType: "user",
|
|
action: "HR_EMERGENCY_CONTACT_REVEAL",
|
|
targetType: "HrEmployeeProfile",
|
|
targetId: profile.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
dataSensitivity: "pii",
|
|
regulatoryScope: ["gdpr"],
|
|
outcome: "success",
|
|
metadata: { employeeUserId: input.userId },
|
|
});
|
|
const plaintext = await crypto.decrypt(input.tenantId, profile.emergencyContactCiphertext);
|
|
return JSON.parse(plaintext) as EmergencyContact;
|
|
}
|
|
|
|
// --- Locations ------------------------------------------------------------
|
|
|
|
export type HrLocationRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
addressLine1: string | null;
|
|
addressLine2: string | null;
|
|
postalCode: string | null;
|
|
city: string | null;
|
|
country: string | null;
|
|
phone: string | null;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
imageFileId: string | null;
|
|
description: string | null;
|
|
isDefault: boolean;
|
|
isDemoData: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
archivedAt: Date | null;
|
|
};
|
|
|
|
export async function listLocations(tenantId: string, opts?: { includeArchived?: boolean }): Promise<HrLocationRow[]> {
|
|
const rows = await withTenantRLS(tenantId, (tx) =>
|
|
tx.hrLocation.findMany({
|
|
where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) },
|
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
|
}),
|
|
);
|
|
return rows as HrLocationRow[];
|
|
}
|
|
|
|
export async function getLocationOrThrow(tenantId: string, locationId: string): Promise<HrLocationRow> {
|
|
const row = await withTenantRLS(tenantId, (tx) => tx.hrLocation.findFirst({ where: { id: locationId, tenantId } }));
|
|
if (!row) {
|
|
throw new CatalogError(foundationErrors.notFound, { locationId });
|
|
}
|
|
return row as HrLocationRow;
|
|
}
|
|
|
|
export type CreateLocationInput = {
|
|
tenantId: string;
|
|
name: string;
|
|
addressLine1?: string | null;
|
|
addressLine2?: string | null;
|
|
postalCode?: string | null;
|
|
city?: string | null;
|
|
country?: string | null;
|
|
phone?: string | null;
|
|
latitude?: number | null;
|
|
longitude?: number | null;
|
|
imageFileId?: string | null;
|
|
description?: string | null;
|
|
isDefault?: boolean;
|
|
isDemoData?: boolean;
|
|
};
|
|
|
|
export async function createLocation(input: CreateLocationInput): Promise<HrLocationRow> {
|
|
const name = input.name.trim();
|
|
if (!name) {
|
|
throw new HrServiceError("hr.error.locationNameRequired");
|
|
}
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
if (input.isDefault) {
|
|
await tx.hrLocation.updateMany({ where: { tenantId: input.tenantId, isDefault: true }, data: { isDefault: false } });
|
|
}
|
|
const created = await tx.hrLocation.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
name: name.slice(0, MAX_NAME_LENGTH),
|
|
addressLine1: input.addressLine1 ?? null,
|
|
addressLine2: input.addressLine2 ?? null,
|
|
postalCode: input.postalCode ?? null,
|
|
city: input.city ?? null,
|
|
country: input.country ?? null,
|
|
phone: input.phone ?? null,
|
|
latitude: input.latitude ?? null,
|
|
longitude: input.longitude ?? null,
|
|
imageFileId: input.imageFileId ?? null,
|
|
description: input.description ?? null,
|
|
isDefault: input.isDefault ?? false,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
});
|
|
return created as HrLocationRow;
|
|
});
|
|
}
|
|
|
|
export type UpdateLocationInput = Partial<Omit<CreateLocationInput, "tenantId">> & { tenantId: string; locationId: string };
|
|
|
|
export async function updateLocation(input: UpdateLocationInput): Promise<void> {
|
|
if (input.name !== undefined && !input.name.trim()) {
|
|
throw new HrServiceError("hr.error.locationNameRequired");
|
|
}
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const existing = await tx.hrLocation.findFirst({ where: { id: input.locationId, tenantId: input.tenantId }, select: { id: true } });
|
|
if (!existing) {
|
|
throw new CatalogError(foundationErrors.notFound, { locationId: input.locationId });
|
|
}
|
|
if (input.isDefault) {
|
|
await tx.hrLocation.updateMany({ where: { tenantId: input.tenantId, isDefault: true, id: { not: input.locationId } }, data: { isDefault: false } });
|
|
}
|
|
await tx.hrLocation.updateMany({
|
|
where: { id: input.locationId, tenantId: input.tenantId },
|
|
data: {
|
|
...(input.name !== undefined ? { name: input.name.trim().slice(0, MAX_NAME_LENGTH) } : {}),
|
|
...(input.addressLine1 !== undefined ? { addressLine1: input.addressLine1 } : {}),
|
|
...(input.addressLine2 !== undefined ? { addressLine2: input.addressLine2 } : {}),
|
|
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
|
|
...(input.city !== undefined ? { city: input.city } : {}),
|
|
...(input.country !== undefined ? { country: input.country } : {}),
|
|
...(input.phone !== undefined ? { phone: input.phone } : {}),
|
|
...(input.latitude !== undefined ? { latitude: input.latitude } : {}),
|
|
...(input.longitude !== undefined ? { longitude: input.longitude } : {}),
|
|
...(input.imageFileId !== undefined ? { imageFileId: input.imageFileId } : {}),
|
|
...(input.description !== undefined ? { description: input.description } : {}),
|
|
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function archiveLocation(input: { tenantId: string; locationId: string }): Promise<void> {
|
|
await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrLocation.updateMany({ where: { id: input.locationId, tenantId: input.tenantId }, data: { archivedAt: new Date(), isDefault: false } }),
|
|
);
|
|
}
|
|
|
|
/** Reassign every employee at `fromLocationId` to `toLocationId` (admin bulk). */
|
|
export async function reassignLocationEmployees(input: { tenantId: string; fromLocationId: string; toLocationId: string }): Promise<number> {
|
|
if (input.fromLocationId === input.toLocationId) {
|
|
throw new HrServiceError("hr.error.locationReassignSame");
|
|
}
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const target = await tx.hrLocation.findFirst({ where: { id: input.toLocationId, tenantId: input.tenantId, archivedAt: null }, select: { id: true } });
|
|
if (!target) {
|
|
throw new CatalogError(foundationErrors.notFound, { locationId: input.toLocationId });
|
|
}
|
|
const result = await tx.hrEmployeeProfile.updateMany({
|
|
where: { tenantId: input.tenantId, locationId: input.fromLocationId },
|
|
data: { locationId: input.toLocationId },
|
|
});
|
|
return result.count;
|
|
});
|
|
}
|
|
|
|
// --- Documents ------------------------------------------------------------
|
|
|
|
export type HrEmployeeDocumentRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
employeeUserId: string;
|
|
uploadedByUserId: string;
|
|
title: string;
|
|
documentKind: HrDocumentKind;
|
|
fileId: string;
|
|
visibility: HrDocumentVisibility;
|
|
uploadedByOffice: boolean;
|
|
validUntil: Date | null;
|
|
isDemoData: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
archivedAt: Date | null;
|
|
};
|
|
|
|
/**
|
|
* List the documents for one employee. When `viewerIsEmployee` (the caller is the
|
|
* employee, not HR), `hr_only` documents are hidden — the employee only ever sees
|
|
* documents flagged `employee`-visible.
|
|
*/
|
|
export async function listEmployeeDocuments(input: {
|
|
tenantId: string;
|
|
employeeUserId: string;
|
|
viewerIsEmployee: boolean;
|
|
includeArchived?: boolean;
|
|
}): Promise<HrEmployeeDocumentRow[]> {
|
|
const rows = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeDocument.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
employeeUserId: input.employeeUserId,
|
|
...(input.includeArchived ? {} : { archivedAt: null }),
|
|
...(input.viewerIsEmployee ? { visibility: "employee" } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
);
|
|
return rows as HrEmployeeDocumentRow[];
|
|
}
|
|
|
|
export type CreateDocumentInput = {
|
|
tenantId: string;
|
|
employeeUserId: string;
|
|
uploadedByUserId: string;
|
|
title: string;
|
|
documentKind?: HrDocumentKind;
|
|
fileId: string;
|
|
visibility?: HrDocumentVisibility;
|
|
uploadedByOffice?: boolean;
|
|
validUntil?: Date | null;
|
|
isDemoData?: boolean;
|
|
};
|
|
|
|
export async function createDocument(input: CreateDocumentInput): Promise<HrEmployeeDocumentRow> {
|
|
const title = input.title.trim();
|
|
if (!title) {
|
|
throw new HrServiceError("hr.error.documentTitleRequired");
|
|
}
|
|
const created = await withTenantRLS(input.tenantId, (tx) =>
|
|
tx.hrEmployeeDocument.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
employeeUserId: input.employeeUserId,
|
|
uploadedByUserId: input.uploadedByUserId,
|
|
title: title.slice(0, MAX_NAME_LENGTH),
|
|
documentKind: input.documentKind ?? "other",
|
|
fileId: input.fileId,
|
|
visibility: input.visibility ?? "employee",
|
|
uploadedByOffice: input.uploadedByOffice ?? false,
|
|
validUntil: input.validUntil ?? null,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
}),
|
|
);
|
|
return created as HrEmployeeDocumentRow;
|
|
}
|
|
|
|
/**
|
|
* Atomically creates the HR document row and its Files ownership link after the
|
|
* object bytes have been accepted by the file service. Callers must hard-delete
|
|
* the newly-created File as compensation when this transaction fails.
|
|
*/
|
|
export async function createDocumentWithLink(input: CreateDocumentInput): Promise<HrEmployeeDocumentRow> {
|
|
const title = input.title.trim();
|
|
if (!title) {
|
|
throw new HrServiceError("hr.error.documentTitleRequired");
|
|
}
|
|
const created = await withTenantRLS(input.tenantId, async (tx) => {
|
|
const file = await tx.file.findFirst({
|
|
where: { id: input.fileId, tenantId: input.tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (!file) {
|
|
throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId });
|
|
}
|
|
const document = await tx.hrEmployeeDocument.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
employeeUserId: input.employeeUserId,
|
|
uploadedByUserId: input.uploadedByUserId,
|
|
title: title.slice(0, MAX_NAME_LENGTH),
|
|
documentKind: input.documentKind ?? "other",
|
|
fileId: input.fileId,
|
|
visibility: input.visibility ?? "employee",
|
|
uploadedByOffice: input.uploadedByOffice ?? false,
|
|
validUntil: input.validUntil ?? null,
|
|
isDemoData: input.isDemoData ?? false,
|
|
},
|
|
});
|
|
await tx.fileEntityLink.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
createdByUserId: input.uploadedByUserId,
|
|
fileId: input.fileId,
|
|
entityType: "user",
|
|
entityId: input.employeeUserId,
|
|
linkRole: "hr_employee_document",
|
|
},
|
|
});
|
|
await logActivity(
|
|
{
|
|
tenantId: input.tenantId,
|
|
actorUserId: input.uploadedByUserId,
|
|
actorKind: "user",
|
|
activityType: "file_uploaded",
|
|
subjectType: "tenant",
|
|
subjectId: input.tenantId,
|
|
summaryText: "activity.runtime.hr.documentUploaded",
|
|
metadata: {
|
|
documentId: document.id,
|
|
employeeUserId: input.employeeUserId,
|
|
process: "hr_document",
|
|
},
|
|
},
|
|
{ client: tx },
|
|
);
|
|
return document as HrEmployeeDocumentRow;
|
|
});
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.uploadedByUserId,
|
|
actorType: "user",
|
|
action: "HR_DOCUMENT_CREATE",
|
|
targetType: "HrEmployeeDocument",
|
|
targetId: created.id,
|
|
sourceModule: HR_AUDIT_MODULE,
|
|
outcome: "success",
|
|
metadata: {
|
|
employeeUserId: input.employeeUserId,
|
|
documentKind: created.documentKind,
|
|
},
|
|
});
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Archive a document. An employee may only archive their OWN, non-office
|
|
* documents (office-uploaded = read-only). HR (viewerIsEmployee=false) may
|
|
* archive any.
|
|
*/
|
|
export async function archiveDocument(input: {
|
|
tenantId: string;
|
|
documentId: string;
|
|
actorUserId: string;
|
|
viewerIsEmployee: boolean;
|
|
}): Promise<void> {
|
|
await withTenantRLS(input.tenantId, async (tx) => {
|
|
const doc = await tx.hrEmployeeDocument.findFirst({
|
|
where: { id: input.documentId, tenantId: input.tenantId },
|
|
select: { id: true, employeeUserId: true, uploadedByOffice: true },
|
|
});
|
|
if (!doc) {
|
|
throw new CatalogError(foundationErrors.notFound, { documentId: input.documentId });
|
|
}
|
|
if (input.viewerIsEmployee) {
|
|
// Employee self-service: own document, and not an office upload.
|
|
if (doc.employeeUserId !== input.actorUserId) {
|
|
throw new CatalogError(foundationErrors.notFound, { documentId: input.documentId });
|
|
}
|
|
if (doc.uploadedByOffice) {
|
|
throw new HrServiceError("hr.error.documentReadOnly");
|
|
}
|
|
}
|
|
await tx.hrEmployeeDocument.updateMany({ where: { id: input.documentId, tenantId: input.tenantId }, data: { archivedAt: new Date() } });
|
|
});
|
|
}
|
|
|
|
// --- Directory helpers (display names) -------------------------------------
|
|
|
|
export type LabeledUser = { id: string; label: string; email: string };
|
|
|
|
export type HrMemberOption = {
|
|
userId: string;
|
|
displayName: string;
|
|
email: string;
|
|
team: string | null;
|
|
position: string | null;
|
|
roleNames: string[];
|
|
};
|
|
|
|
/** Active tenant members for named employee/approver pickers. */
|
|
export async function listHrMemberOptions(tenantId: string): Promise<HrMemberOption[]> {
|
|
return withTenantRLS(tenantId, async (tx) => {
|
|
const [memberships, profiles, roles] = await Promise.all([
|
|
tx.tenantUser.findMany({
|
|
where: { tenantId, status: TenantUserStatus.ACTIVE, user: { active: true } },
|
|
select: {
|
|
userId: true,
|
|
user: { select: { firstName: true, lastName: true, email: true } },
|
|
},
|
|
orderBy: { user: { lastName: "asc" } },
|
|
}),
|
|
tx.hrEmployeeProfile.findMany({
|
|
where: { tenantId },
|
|
select: { userId: true, team: true, position: true },
|
|
}),
|
|
tx.userRole.findMany({
|
|
where: { tenantId },
|
|
select: { userId: true, role: { select: { name: true } } },
|
|
}),
|
|
]);
|
|
const profileByUser = new Map(profiles.map((profile) => [profile.userId, profile]));
|
|
const roleNamesByUser = new Map<string, string[]>();
|
|
for (const role of roles) {
|
|
const names = roleNamesByUser.get(role.userId) ?? [];
|
|
names.push(role.role.name);
|
|
roleNamesByUser.set(role.userId, names);
|
|
}
|
|
return memberships.map(({ userId, user }) => {
|
|
const profile = profileByUser.get(userId);
|
|
const name = `${user.firstName} ${user.lastName}`.trim();
|
|
return {
|
|
userId,
|
|
displayName: name || user.email,
|
|
email: user.email,
|
|
team: profile?.team ?? null,
|
|
position: profile?.position ?? null,
|
|
roleNames: roleNamesByUser.get(userId) ?? [],
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Validates a posted picker target against the active tenant membership. */
|
|
export async function requireActiveHrMember(tenantId: string, userId: string): Promise<void> {
|
|
const member = await withTenantRLS(tenantId, (tx) =>
|
|
tx.tenantUser.findFirst({
|
|
where: { tenantId, userId, status: TenantUserStatus.ACTIVE, user: { active: true } },
|
|
select: { id: true },
|
|
}),
|
|
);
|
|
if (!member) {
|
|
throw new CatalogError(foundationErrors.notFound, { userId });
|
|
}
|
|
}
|
|
|
|
function userLabel(user: { firstName: string; lastName: string; email: string }): string {
|
|
const name = `${user.firstName} ${user.lastName}`.trim();
|
|
return name || user.email;
|
|
}
|
|
|
|
/** Resolve display labels for a set of user ids (global User table, not tenant-scoped). */
|
|
export async function resolveUserLabels(userIds: readonly string[]): Promise<Map<string, LabeledUser>> {
|
|
const ids = [...new Set(userIds)].filter(Boolean);
|
|
if (ids.length === 0) {
|
|
return new Map();
|
|
}
|
|
const users = await db.user.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, firstName: true, lastName: true, email: true },
|
|
});
|
|
return new Map(users.map((user) => [user.id, { id: user.id, label: userLabel(user), email: user.email }]));
|
|
}
|
|
|
|
// Re-export the transaction client type for the password-safe service (shared
|
|
// RLS transaction shape).
|
|
export type { TenantRlsClient };
|