Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/prisma/schema/hr.prisma
T
2026-07-08 16:15:37 +02:00

680 lines
24 KiB
Plaintext

// P17 HR-Hub core (AK-P17a) — employee master data, locations, personnel
// documents and the encrypted team password safe.
//
// AD-21: one self-contained schema file per module. `tenantId` / `userId` /
// `*UserId` / `fileId` / `locationId` are intentionally BARE String scalars (no
// `@relation`) so this file has zero merge-conflict surface against
// `core.prisma`/`accounts.prisma` (no back-relation fields on Tenant/User
// needed). Referential integrity is carried by app-level scoping (the hr*Service
// helpers) plus the RLS policies from the merge migration (see
// prisma/rls/hr-isolation.sql). Documents reference File from files.prisma
// (AK-P4a/b) by bare `fileId` for the same reason.
//
// Scope v1 (AK-P17a ticket doc, strict): employee master data + locations +
// personnel documents + password safe ONLY. Announcements, leave/sick reports,
// payslips, requests, surveys and upload reminders belong to AK-P17b / follow-up
// and are intentionally NOT modelled here.
//
// ENCRYPTION (AD-8, deferred): sensitive values — the password-safe entry
// secrets and the employee emergency contact — are stored as ENVELOPE CIPHERTEXT
// strings (the `*Ciphertext` columns). Per the AK-P17a build decision the
// per-tenant DEK / KMS envelope layer is a separate infrastructure ticket; the
// service layer routes every read/write of these columns through the
// `hrFieldCrypto` seam (apps/web/app/utils/integrations/hrFieldCrypto.server.ts),
// whose production binding currently defers to that follow-up ticket. The schema
// is final; only the crypto binding is pending.
/// Visibility of an HR personnel document to the employee it belongs to.
/// `employee` = the employee can see (and, if self-uploaded, manage) it;
/// `hr_only` = visible to HR admins only.
enum HrDocumentVisibility {
employee
hr_only
}
/// Coarse kind of a personnel document (for filtering + upload reminders).
enum HrDocumentKind {
contract
certificate
identification
qualification
other
}
/// A linked HR resource / quick link kind.
enum HrResourceKind {
link
file
}
/// Access level a user holds on a password-safe vault. `read` may reveal/copy
/// values; `write` may also create/edit entries; `admin` may also manage access
/// and archive/delete the vault.
enum PasswordSafePermissionLevel {
read
write
admin
}
/// Per-tenant employee master record (1:1 with a global User). Self-maintainable
/// fields (e.g. position display, team) are edited by the employee; HR-managed
/// fields by HR admins (enforced in the service layer). The emergency contact is
/// sensitive and stored ENVELOPE-ENCRYPTED (`emergencyContactCiphertext`, holds
/// the name/phone/relation triple as encrypted JSON) — never returned without an
/// explicit, permission-checked reveal.
model HrEmployeeProfile {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
userId String
personalNumber String?
hireDate DateTime?
position String?
team String?
locationId String?
managerUserId String?
// Sensitive: AES-256-GCM envelope ciphertext (name/phone/relation as JSON).
emergencyContactCiphertext String?
isDemoData Boolean @default(false)
@@unique([tenantId, userId])
@@index([tenantId, locationId])
@@index([tenantId, managerUserId])
@@index([tenantId, team])
}
/// A physical company location, scoped per tenant. Soft-delete via `archivedAt`.
/// Exactly one location may be the tenant default (`isDefault`); enforced in the
/// service layer (set-default clears the previous default).
model HrLocation {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
name String
addressLine1 String?
addressLine2 String?
postalCode String?
city String?
country String?
phone String?
latitude Float?
longitude Float?
imageFileId String?
description String?
isDefault Boolean @default(false)
isDemoData Boolean @default(false)
archivedAt DateTime?
@@index([tenantId, archivedAt, name])
@@index([tenantId, isDefault])
}
/// A personnel document for one employee. The bytes live in a File (files.prisma,
/// referenced by bare `fileId`). `visibility` gates whether the employee sees it;
/// `uploadedByOffice` marks office-uploaded documents as READ-ONLY for the
/// employee (they may view/download but not edit or delete). Soft-delete via
/// `archivedAt`.
model HrEmployeeDocument {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
employeeUserId String
uploadedByUserId String
title String
documentKind HrDocumentKind @default(other)
fileId String
visibility HrDocumentVisibility @default(employee)
// True when uploaded by HR/office on behalf of the employee → read-only for
// the employee in the self-service "My documents" view.
uploadedByOffice Boolean @default(false)
validUntil DateTime?
isDemoData Boolean @default(false)
archivedAt DateTime?
@@index([tenantId, employeeUserId, archivedAt])
@@index([tenantId, documentKind])
@@index([tenantId, validUntil])
}
/// A shared HR resource (a link or a file) surfaced on the HR hub. Soft-delete
/// via `archivedAt`. `audience` is a coarse audience marker (e.g. "all",
/// "team:<id>"); fine-grained targeting is follow-up.
model HrResource {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
title String
kind HrResourceKind @default(link)
url String?
fileId String?
category String?
audience String @default("all")
sortOrder Int @default(0)
isDemoData Boolean @default(false)
archivedAt DateTime?
@@index([tenantId, archivedAt, category, sortOrder])
}
/// A quick link tile on the HR hub. Soft-delete via `archivedAt`.
model HrQuickLink {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
title String
url String
icon String?
audience String @default("all")
sortOrder Int @default(0)
isDemoData Boolean @default(false)
archivedAt DateTime?
@@index([tenantId, archivedAt, sortOrder])
}
/// A password-safe vault, scoped per tenant. Access is NOT implied by tenant
/// membership — a user only sees a vault they hold an EmployeePasswordSafeAccess
/// row for (or via the tenant-wide admin view, which never grants content
/// access). Soft-delete via `archivedAt`.
model EmployeePasswordSafeVault {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
name String
description String?
createdByUserId String
isDemoData Boolean @default(false)
archivedAt DateTime?
entries EmployeePasswordSafeEntry[]
access EmployeePasswordSafeAccess[]
@@index([tenantId, archivedAt, name])
}
/// A single secret in a vault. The secret values are stored ENVELOPE-ENCRYPTED
/// (`*Ciphertext`) and are NEVER selected into a list view — only an explicit,
/// permission-checked + audited reveal decrypts a single value on demand.
model EmployeePasswordSafeEntry {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
vaultId String
title String
tags String[] @default([])
url String?
// Sensitive: AES-256-GCM envelope ciphertext. Never returned in list queries.
usernameCiphertext String?
passwordCiphertext String?
notesCiphertext String?
isDemoData Boolean @default(false)
archivedAt DateTime?
vault EmployeePasswordSafeVault @relation(fields: [vaultId], references: [id], onDelete: Cascade)
@@index([tenantId, vaultId, archivedAt])
}
/// A user's access grant on a vault (M:N user↔vault with a permission level).
model EmployeePasswordSafeAccess {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
vaultId String
userId String
permissionLevel PasswordSafePermissionLevel @default(read)
grantedByUserId String
vault EmployeePasswordSafeVault @relation(fields: [vaultId], references: [id], onDelete: Cascade)
@@unique([vaultId, userId])
@@index([tenantId, userId])
}
// =============================================================================
// AK-P17b · P17 HR-Hub workflows — leave/sick, announcements, payslips, upload
// reminders, requests and surveys. Additive to this file (AD-21); same bare
// String-scalar convention (no `@relation` to core/User/File) so the merge
// surface against core.prisma/accounts.prisma stays zero. CalendarEvent (P16,
// M25) and the M05 FormSubmission (AK-P16c) are referenced by NULLABLE bare
// scalars only — both land after P17, so the actual calendar sync / form-render
// reuse is a deferred Dev-Nachzug; the columns are final.
//
// ENCRYPTION (AD-8): the sick-leave certificate ("AU-Schein") is sensitive. We
// envelope-encrypt the certificate File REFERENCE (`certificateRefCiphertext`)
// through the hrFieldCrypto seam (mirrors HrEmployeeProfile.emergencyContact):
// the column never reveals which File is the certificate without an explicit,
// permission-checked + audited reveal. (At-rest encryption of the File BYTES is
// a File-platform concern, tracked separately.)
// =============================================================================
/// Kind of a vacation/leave request.
enum HrVacationRequestKind {
vacation
special_leave
unpaid_leave
overtime_compensation
}
/// Approval state of a leave request. `clarification` = approver asked the
/// requester a question (Rückfrage) and awaits a reply before deciding.
enum HrLeaveRequestStatus {
submitted
clarification
approved
rejected
withdrawn
}
/// State of a sick-leave report.
enum HrSickLeaveStatus {
reported
closed
cancelled
}
/// Coarse audience marker for announcements / surveys / upload reminders.
/// `team`/`location` pair with `audienceRef` (the team or location id).
enum HrAudienceKind {
all
team
location
}
/// Kind of a generic HR request (template-driven).
enum HrRequestKind {
expense
hardware
training
reference
}
/// Approval state of an HR request (mirrors leave).
enum HrRequestStatus {
submitted
clarification
approved
rejected
withdrawn
}
/// Cadence of an upload reminder. `every_n_months` reads `intervalMonths`;
/// `on_expiry` derives the due date from a document's `validUntil`.
enum HrReminderCadenceKind {
once
yearly
every_n_months
on_expiry
}
/// Lifecycle of an upload-reminder definition.
enum HrReminderStatus {
active
paused
completed
}
/// State of a single dispatched reminder to one employee.
enum HrReminderDispatchStatus {
pending
sent
escalated
acknowledged
}
/// Survey item answer type.
enum HrSurveyItemType {
text
rating_5
nps
choice
boolean
date
}
/// Who may see a survey's aggregated results.
enum HrSurveyResultVisibility {
admins
participants
everyone
}
/// Lifecycle of a survey.
enum HrSurveyStatus {
draft
open
closed
}
/// A multi-step leave/vacation request. State machine: submitted →
/// {clarification ↔ submitted} → approved | rejected, or → withdrawn by the
/// requester. Only `approved` requests feed the absence calendar / plantafel
/// conflict feed (R-P9b). `calendarEventId` stays null until the P16 calendar
/// sync lands (deferred).
model EmployeeVacationRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
employeeUserId String
requestKind HrVacationRequestKind @default(vacation)
startDate DateTime
endDate DateTime
note String?
status HrLeaveRequestStatus @default(submitted)
assignedApproverUserId String?
clarificationNote String?
decidedByUserId String?
decidedAt DateTime?
decisionNote String?
withdrawnAt DateTime?
// Deferred: CalendarEvent sync lands with P16 (M25). Bare nullable scalar.
calendarEventId String?
isDemoData Boolean @default(false)
@@index([tenantId, employeeUserId, status])
@@index([tenantId, assignedApproverUserId, status])
@@index([tenantId, status, startDate, endDate])
}
/// A sick-leave report, filed by the employee or by HR/office on their behalf
/// (`reportedByUserId`). The AU certificate reference is stored ENVELOPE-
/// ENCRYPTED (`certificateRefCiphertext`) and never returned without a
/// permission-checked + audited reveal.
model EmployeeSickLeaveReport {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
employeeUserId String
reportedByUserId String
startDate DateTime
expectedReturnDate DateTime?
returnDate DateTime?
// Sensitive: AES-256-GCM envelope ciphertext of the certificate File id.
certificateRefCiphertext String?
status HrSickLeaveStatus @default(reported)
note String?
// Deferred: CalendarEvent sync lands with P16 (M25).
calendarEventId String?
isDemoData Boolean @default(false)
@@index([tenantId, employeeUserId, status])
@@index([tenantId, status, startDate])
}
/// A company announcement. Markdown `body`, audience targeting, optional
/// acknowledgement requirement (read-receipt), scheduling (`publishAt`) and
/// expiry. Soft-delete via `archivedAt`.
model HrAnnouncement {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
authorUserId String
title String
body String
audienceKind HrAudienceKind @default(all)
audienceRef String?
requiresAcknowledgement Boolean @default(false)
pinnedAt DateTime?
publishAt DateTime?
expiresAt DateTime?
isDemoData Boolean @default(false)
archivedAt DateTime?
reads HrAnnouncementRead[]
reactions HrAnnouncementReaction[]
comments HrAnnouncementComment[]
@@index([tenantId, archivedAt, pinnedAt])
@@index([tenantId, publishAt])
}
/// Per-user read / acknowledgement receipt for an announcement.
/// `acknowledgedAt` is set only when the announcement required acknowledgement
/// and the user clicked confirm.
model HrAnnouncementRead {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
announcementId String
userId String
acknowledgedAt DateTime?
announcement HrAnnouncement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
@@unique([announcementId, userId])
@@index([tenantId, userId])
}
/// A single emoji reaction by a user on an announcement.
model HrAnnouncementReaction {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
announcementId String
userId String
reaction String
announcement HrAnnouncement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
@@unique([announcementId, userId, reaction])
@@index([tenantId, announcementId])
}
/// A single-level comment on an announcement. Soft-delete via `archivedAt`.
model HrAnnouncementComment {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
announcementId String
userId String
body String
archivedAt DateTime?
announcement HrAnnouncement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
@@index([tenantId, announcementId, archivedAt])
}
/// A payslip PDF for one employee for one period. The bytes live in a File
/// (referenced by bare `fileId`). Opening the payslip marks it read
/// (`isRead`/`readAt`) and writes an audit event per read. Soft-delete via
/// `archivedAt`.
model EmployeePayslip {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
employeeUserId String
uploadedByUserId String
periodYear Int
periodMonth Int
fileId String
isRead Boolean @default(false)
readAt DateTime?
isDemoData Boolean @default(false)
archivedAt DateTime?
@@unique([tenantId, employeeUserId, periodYear, periodMonth])
@@index([tenantId, employeeUserId, archivedAt])
}
/// An upload-reminder definition: ask a targeted audience to upload a document
/// of a given kind on a cadence, optionally mandatory and escalating to the
/// manager when overdue. `linkedFormId` (M05) stays null until AK-P16c.
/// Soft-delete via `archivedAt`.
model EmployeeUploadReminder {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
title String
documentKind HrDocumentKind @default(other)
cadenceKind HrReminderCadenceKind @default(once)
intervalMonths Int?
audienceKind HrAudienceKind @default(all)
audienceRef String?
mandatory Boolean @default(false)
escalateToManager Boolean @default(false)
// M05 form template reference; null until AK-P16c renderer lands (deferred).
linkedFormId String?
dueAt DateTime?
status HrReminderStatus @default(active)
isDemoData Boolean @default(false)
archivedAt DateTime?
dispatches EmployeeUploadReminderDispatch[]
@@index([tenantId, status, dueAt])
}
/// One reminder dispatched to one employee for one due date. Idempotent per
/// (reminder, employee, dueAt) so the cadence worker never double-sends.
model EmployeeUploadReminderDispatch {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
reminderId String
employeeUserId String
dueAt DateTime
status HrReminderDispatchStatus @default(pending)
sentAt DateTime?
acknowledgedAt DateTime?
escalatedAt DateTime?
reminder EmployeeUploadReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
@@unique([reminderId, employeeUserId, dueAt])
@@index([tenantId, employeeUserId, status])
}
/// A reusable HR request template (expense/hardware/training/reference) with an
/// optional default approver and linked M05 form (`linkedFormId`, null until
/// AK-P16c). Soft-delete via `archivedAt`.
model HrRequestTemplate {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
title String
requestKind HrRequestKind
description String?
defaultApproverUserId String?
linkedFormId String?
isActive Boolean @default(true)
isDemoData Boolean @default(false)
archivedAt DateTime?
requests HrRequest[]
@@index([tenantId, archivedAt, requestKind])
}
/// A generic HR request submitted by an employee, optionally from a template.
/// Until the M05 renderer (AK-P16c) lands, the payload is captured as a JSON
/// string in `details` (transitional form); `formSubmissionId` is wired in
/// later. Approval mirrors the leave state machine. Soft-delete via `archivedAt`.
model HrRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
employeeUserId String
templateId String?
requestKind HrRequestKind
title String
// Transitional form payload (JSON string) until M05 submission is wired.
details String?
formSubmissionId String?
status HrRequestStatus @default(submitted)
assignedApproverUserId String?
clarificationNote String?
decidedByUserId String?
decidedAt DateTime?
decisionNote String?
withdrawnAt DateTime?
isDemoData Boolean @default(false)
archivedAt DateTime?
template HrRequestTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
@@index([tenantId, employeeUserId, status])
@@index([tenantId, assignedApproverUserId, status])
}
/// A survey. May be anonymous (`isAnonymous`); aggregated results are gated by
/// `resultVisibility`. Soft-delete via `archivedAt`.
model HrSurvey {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
authorUserId String
title String
description String?
isAnonymous Boolean @default(false)
resultVisibility HrSurveyResultVisibility @default(admins)
audienceKind HrAudienceKind @default(all)
audienceRef String?
status HrSurveyStatus @default(draft)
opensAt DateTime?
closesAt DateTime?
isDemoData Boolean @default(false)
archivedAt DateTime?
items HrSurveyItem[]
submissions HrSurveySubmission[]
@@index([tenantId, status, archivedAt])
}
/// One question in a survey. `options` holds the choices for `choice` items.
model HrSurveyItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenantId String
surveyId String
itemType HrSurveyItemType
prompt String
required Boolean @default(false)
options String[] @default([])
sortOrder Int @default(0)
survey HrSurvey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
@@index([tenantId, surveyId])
}
/// One submission to a survey. For identified surveys `submitterUserId` is set
/// and unique per survey; for anonymous surveys it is null and `submitterHash`
/// (a non-reversible per-survey hash of the user) enforces one-submission
/// without re-identification. `answers` maps item id → answer value.
model HrSurveySubmission {
id String @id @default(cuid())
createdAt DateTime @default(now())
tenantId String
surveyId String
submitterUserId String?
submitterHash String?
answers Json
survey HrSurvey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
@@unique([surveyId, submitterUserId])
@@unique([surveyId, submitterHash])
@@index([tenantId, surveyId])
}