// 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>; /** Load a single employee profile (tenant-scoped), or null. */ export async function getProfileByUser(tenantId: string, userId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> & { tenantId: string; locationId: string }; export async function updateLocation(input: UpdateLocationInput): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); 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 { 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> { 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 };