Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrService.server.ts
T

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 };