Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+418 -29
View File
@@ -63,6 +63,17 @@ enum TerminStatus {
CANCELLED
}
enum RsvpMode {
CHILD
FAMILY
}
enum AnnouncementAudience {
ELTERN
ERZIEHER
ALLE
}
enum EventParticipationStatus {
ATTENDING
NOT_ATTENDING
@@ -84,6 +95,15 @@ enum NotdienstAlertStatus {
CANCELLED
}
enum NotdienstAlarmStatus {
PENDING
CONFIRMED
DECLINED
LATE_CONFIRMATION
REVIEW_REQUIRED
CANCELLED
}
enum NotdienstSwapStatus {
PENDING
ACCEPTED
@@ -143,7 +163,11 @@ model Kita {
notdienstAvailabilities NotdienstAvailability[]
notdienstAssignments NotdienstAssignment[]
notdienstAlerts NotdienstAlert[]
notdienstAlarms NotdienstAlarm[]
notdienstSwapRequests NotdienstSwapRequest[]
staffProfiles StaffProfile[]
staffHolidays StaffHoliday[]
familyEventParticipations FamilyEventParticipation[]
@@map("kitas")
}
@@ -158,15 +182,17 @@ model Family {
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
name String
name String
notdienstAlarms NotdienstAlarm[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
children Child[]
dutyAssignments DutyAssignment[]
users User[]
children Child[]
dutyAssignments DutyAssignment[]
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequesterFamily")
familyEventParticipations FamilyEventParticipation[]
@@index([kitaId])
@@map("families")
@@ -212,6 +238,7 @@ model User {
privacyPolicyVersion String?
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
directoryOptInAt DateTime?
staffDecisionEmails Boolean @default(true)
// Auth-Bookkeeping
emailVerifiedAt DateTime?
@@ -227,21 +254,27 @@ model User {
// Relations
dutyAssignments ParentDutyAssignment[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
announcementReads AnnouncementRead[]
notdienstAvailabilities NotdienstAvailability[]
notdienstAlertsAssigned NotdienstAlert[] @relation("NotdienstAlertParent")
notdienstAlertsTriggered NotdienstAlert[] @relation("NotdienstAlertTrigger")
notdienstPlansCreated NotdienstPlan[] @relation("NotdienstPlanCreator")
notdienstSwapRequestsStarted NotdienstSwapRequest[] @relation("NotdienstSwapRequester")
notdienstSwapRequestsResponded NotdienstSwapRequest[] @relation("NotdienstSwapResponder")
announcementsAuthored Announcement[] @relation("AnnouncementAuthor")
announcementReads AnnouncementRead[]
termineCreated Termin[] @relation("TerminCreator")
termineApproved Termin[] @relation("TerminApprover")
mitbringselItems MitbringselItem[]
invitationsCreated Invitation[] @relation("InvitationCreator")
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
invitationsCreated Invitation[] @relation("InvitationCreator")
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
staffProfile StaffProfile?
staffNotifications StaffNotification[]
staffEmails StaffEmail[]
alarmRecipients NotdienstAlarmRecipient[]
alarmNotifications NotdienstAlarmNotification[]
alarmEmails NotdienstAlarmEmail[]
@@index([kitaId])
@@index([familyId])
@@ -300,12 +333,238 @@ model Educator {
updatedAt DateTime @updatedAt
notdienstAlerts NotdienstAlert[]
staffProfile StaffProfile?
@@index([kitaId])
@@map("educators")
@@unique([id, kitaId])
}
// Personalbereich: Tage als UTC-Datum, Zeiten als Minuten, Anspruch in halben Tagen.
enum StaffRequestStatus {
PENDING
APPROVED
REJECTED
CANCEL_REQUESTED
CANCELLED
}
enum StaffLeaveKind {
VACATION
TIME_OFF
SICKNESS
}
enum StaffWeekStatus {
DRAFT
SUBMITTED
APPROVED
}
model StaffProfile {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
educatorId String @unique
educator Educator @relation(fields: [educatorId], references: [id], onDelete: NoAction)
userId String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
employmentStart DateTime @db.Date
employmentEnd DateTime? @db.Date
trackingStart DateTime @db.Date
openingMinutes Int @default(0)
createdAt DateTime @default(now())
workPatterns StaffWorkPattern[]
dayOverrides StaffDayOverride[]
leaveAccounts StaffLeaveAccount[]
leaves StaffLeave[]
timeEntries StaffTimeEntry[]
weeks StaffWeek[]
adjustments StaffAdjustment[]
history StaffHistory[]
notifications StaffNotification[]
emails StaffEmail[]
@@index([kitaId])
@@map("staff_profiles")
}
// Persisted in the same transaction as the staff action; delivered after commit.
model StaffEmail {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
toAdmins Boolean @default(false)
subject String
message String
path String
inviteToken String?
createdAt DateTime @default(now())
sentAt DateTime?
skippedAt DateTime?
leaseUntil DateTime?
attempts Int @default(0)
failed Boolean @default(false)
@@index([profileId, sentAt, skippedAt])
@@map("staff_emails")
}
model StaffWorkPattern {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
effectiveFrom DateTime @db.Date
// Montag bis Sonntag; 0 bedeutet arbeitsfrei.
dailyMinutes Int[]
@@unique([profileId, effectiveFrom])
@@map("staff_work_patterns")
}
model StaffDayOverride {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
date DateTime @db.Date
minutes Int
reason String
@@unique([profileId, date])
@@map("staff_day_overrides")
}
model StaffHoliday {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
date DateTime @db.Date
name String
@@unique([kitaId, date])
@@map("staff_holidays")
}
model StaffLeaveAccount {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
year Int
entitlementUnits Int
carryUnits Int @default(0)
@@unique([profileId, year])
@@map("staff_leave_accounts")
}
model StaffLeave {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
kind StaffLeaveKind
status StaffRequestStatus @default(PENDING)
startDate DateTime @db.Date
endDate DateTime @db.Date
halfDay Boolean @default(false)
minutes Int?
note String?
decisionNote String?
requestedById String
reviewedById String?
reviewedAt DateTime?
createdAt DateTime @default(now())
days StaffLeaveDay[]
@@index([profileId, status, startDate])
@@map("staff_leaves")
}
model StaffLeaveDay {
id String @id @default(cuid())
leaveId String
leave StaffLeave @relation(fields: [leaveId], references: [id], onDelete: Cascade)
date DateTime @db.Date
vacationUnits Int @default(0)
creditMinutes Int
@@unique([leaveId, date])
@@map("staff_leave_days")
}
model StaffTimeEntry {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
date DateTime @db.Date
startMinute Int
endMinute Int
breakMinutes Int @default(0)
note String?
@@index([profileId, date])
@@map("staff_time_entries")
}
model StaffWeek {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
weekStart DateTime @db.Date
status StaffWeekStatus @default(DRAFT)
submittedAt DateTime?
reviewedAt DateTime?
reviewedById String?
decisionNote String?
// Snapshot von Soll-, Ist- und Abwesenheitsminuten bei Genehmigung.
dailySnapshot Json?
@@unique([profileId, weekStart])
@@map("staff_weeks")
}
model StaffAdjustment {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
date DateTime @db.Date
minutes Int
reason String
actorId String
createdAt DateTime @default(now())
@@index([profileId, date])
@@map("staff_adjustments")
}
model StaffHistory {
id String @id @default(cuid())
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
actorId String
action String
details String
createdAt DateTime @default(now())
@@index([profileId, createdAt])
@@map("staff_history")
}
model StaffNotification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
profileId String
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
message String
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt, createdAt])
@@map("staff_notifications")
}
// =====================================================================
// PARENT DUTIES (Feste Ämter / Elterndienste, Modul 3)
// =====================================================================
@@ -430,8 +689,9 @@ model Announcement {
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
title String
content String
title String
content String
audience AnnouncementAudience @default(ALLE)
authorId String
author User @relation("AnnouncementAuthor", fields: [authorId], references: [id], onDelete: Cascade)
@@ -528,6 +788,7 @@ model Termin {
/// Teilnahme-Abfrage für Ausflüge und besondere Events.
requiresRsvp Boolean @default(false)
rsvpMode RsvpMode @default(CHILD)
rsvpDeadline DateTime?
participantInfo String?
@@ -542,8 +803,9 @@ model Termin {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
mitbringselItems MitbringselItem[]
participations EventParticipation[]
mitbringselItems MitbringselItem[]
participations EventParticipation[]
familyParticipations FamilyEventParticipation[]
@@index([kitaId, startDate])
@@index([kitaId, status])
@@ -573,6 +835,25 @@ model EventParticipation {
@@map("event_participations")
}
model FamilyEventParticipation {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
eventId String
event Termin @relation(fields: [eventId], references: [id], onDelete: Cascade)
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
status EventParticipationStatus
adultCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([eventId, familyId])
@@index([kitaId])
@@index([familyId])
@@map("family_event_participations")
}
model MitbringselItem {
id String @id @default(cuid())
kitaId String
@@ -671,7 +952,8 @@ model NotdienstAssignment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
alerts NotdienstAlert[]
alerts NotdienstAlert[]
alarms NotdienstAlarm[]
swapRequestsAsTarget NotdienstSwapRequest[] @relation("NotdienstSwapTargetAssignment")
swapRequestsAsOffered NotdienstSwapRequest[] @relation("NotdienstSwapOfferedAssignment")
@@ -700,11 +982,11 @@ model NotdienstSwapRequest {
offeredAssignmentId String
offeredAssignment NotdienstAssignment @relation("NotdienstSwapOfferedAssignment", fields: [offeredAssignmentId], references: [id], onDelete: Cascade)
status NotdienstSwapStatus @default(PENDING)
status NotdienstSwapStatus @default(PENDING)
message String?
respondedById String?
respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
respondedBy User? @relation("NotdienstSwapResponder", fields: [respondedById], references: [id], onDelete: SetNull)
respondedAt DateTime?
createdAt DateTime @default(now())
@@ -719,10 +1001,117 @@ model NotdienstSwapRequest {
/// Aktive Alarmierung — wird bei Krankmeldung einer Fachkraft
/// vom Koordinator ausgelöst. Bestätigung via Magic-Link (Token).
model NotdienstAlarm {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
assignmentId String?
assignment NotdienstAssignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull)
// Nullable unique reservation: cancelled or ended alarms release the assignment.
activeAssignmentId String? @unique
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
familyName String
childName String
date DateTime @db.Date
wholeDay Boolean @default(true)
startMinute Int?
endMinute Int?
expiresAt DateTime
deadline DateTime
status NotdienstAlarmStatus @default(PENDING)
triggeredById String?
triggeredAt DateTime @default(now())
respondedById String?
respondedAt DateTime?
responseSource String?
escalatedAt DateTime?
educatorId String?
notes String?
cancelledAt DateTime?
cancelReason String?
recipients NotdienstAlarmRecipient[]
notifications NotdienstAlarmNotification[]
emails NotdienstAlarmEmail[]
history NotdienstAlarmHistory[]
legacyAlerts NotdienstAlert[]
@@index([kitaId, date])
@@index([status, deadline])
@@map("notdienst_alarms")
}
model NotdienstAlarmRecipient {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
sessionVersion Int @default(0)
@@unique([alarmId, userId])
@@map("notdienst_alarm_recipients")
}
model NotdienstAlarmNotification {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
eventKey String
message String
createdAt DateTime @default(now())
readAt DateTime?
@@unique([alarmId, userId, eventKey])
@@index([userId, readAt])
@@map("notdienst_alarm_notifications")
}
model NotdienstAlarmEmail {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
eventKey String
kind String
subject String
message String
path String
createdAt DateTime @default(now())
sentAt DateTime?
skippedAt DateTime?
leaseUntil DateTime?
failed Boolean @default(false)
attempts Int @default(0)
@@unique([alarmId, userId, eventKey])
@@index([sentAt, skippedAt, createdAt])
@@map("notdienst_alarm_emails")
}
model NotdienstAlarmHistory {
id String @id @default(cuid())
alarmId String
alarm NotdienstAlarm @relation(fields: [alarmId], references: [id], onDelete: Cascade)
actorId String?
action String
details String
createdAt DateTime @default(now())
@@index([alarmId, createdAt])
@@map("notdienst_alarm_history")
}
model NotdienstAlert {
id String @id @default(cuid())
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
alarmId String?
alarm NotdienstAlarm? @relation(fields: [alarmId], references: [id], onDelete: SetNull)
kitaId String
kita Kita @relation(fields: [kitaId], references: [id], onDelete: Cascade)
assignmentId String
assignment NotdienstAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
@@ -771,13 +1160,13 @@ model AuditLog {
actorUser User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
actorEmail String
action String
targetType AuditTargetType
targetId String
targetLabel String
action String
targetType AuditTargetType
targetId String
targetLabel String
targetKitaId String?
metadata Json?
metadata Json?
createdAt DateTime @default(now())
@@index([actorUserId])