wg-verwaltung/prisma/schema.prisma
bernd 39220bddf4 Sync trash calendar from official Nackenheim/KAW waste collection API
Adds trashCalendarSyncJob.ts, which pulls Restmüll/Biomüll/Gelbe Tonne/
Papiertonne dates for Nackenheim (CityId 35, Verbandsgemeinde Bodenheim)
from the public, keyless API behind lk.kaw-mainz-bingen.de's official
waste calendar, and upserts them into trash_schedule. Adds a unique
(type, date) constraint to prevent duplicate entries on reruns. Run via
`npm run trash-sync:run`, same standalone-script pattern as the other
reminder jobs.
2026-08-13 08:58:21 +00:00

715 lines
23 KiB
Plaintext

// ============================================================================
// WG- & Vermieter-Management-App — Prisma Schema (PostgreSQL)
// Objekt: 170 qm 4er-WG, Nackenheim (Mainz), Einzelzimmervermietung
// ============================================================================
generator client {
provider = "prisma-client-js"
// Explizit gesetzt, da schema.prisma im Repo-Root liegt, aber node_modules
// nur in backend/ existiert: ohne dieses `output` sucht Prisma das
// "Projekt-Root" ausgehend vom Schema-Pfad nach oben, findet dort kein
// package.json und weicht auf einen unzuverlässigen Auto-Install-Fallback
// aus (schreibt dann z. B. nach /root/node_modules statt backend/).
output = "../backend/node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ----------------------------------------------------------------------------
// ENUMS
// ----------------------------------------------------------------------------
enum UserRole {
LANDLORD
TENANT
CRAFTSMAN
ADMIN // Property-Manager / Vertretung des Vermieters
}
enum RoomStatus {
OCCUPIED
VACANT
MAINTENANCE
}
enum RentAdjustmentType {
NONE
INDEX_MIETE // Indexmietklausel
STAFFEL_MIETE // Staffelmietklausel
}
enum PaymentStatus {
PENDING
PAID
PARTIAL
OVERDUE
MATCH_UNCERTAIN // Betrag/Referenz weicht ab -> manuelle Prüfung nötig
}
enum TicketCategory {
SANITAER
ELEKTRIK
MOEBEL
HEIZUNG
SCHIMMEL
SCHLUESSEL_SCHLOSS
SONSTIGES
}
enum TicketPriority {
LOW
MEDIUM
HIGH
EMERGENCY
}
enum TicketStatus {
OPEN
IN_PROGRESS
RESOLVED
CLOSED
}
enum CleaningArea {
BATHROOM_1
BATHROOM_2
KITCHEN
HALLWAY_LAUNDRY
}
enum CleaningStatus {
PENDING
COMPLETED
VERIFIED
MISSED
}
enum ItemCondition {
NEW
GOOD
WEAR_AND_TEAR
DAMAGED
}
enum ExpenseCategory {
HAUSHALT // Putzmittel, Klopapier etc.
LEBENSMITTEL_GEMEINSAM
REPARATUR_VORAUSLAGE
SONSTIGES
}
enum SettlementStatus {
OPEN
SETTLED
}
enum HandoverType {
MOVE_IN
MOVE_OUT
}
enum GuestCodeStatus {
ACTIVE
EXPIRED
REVOKED
}
enum InvitationStatus {
PENDING
ACCEPTED
EXPIRED
REVOKED
}
enum StorageLocation {
FRIDGE
FREEZER
KITCHEN_CABINET_1
KITCHEN_CABINET_2
KITCHEN_CABINET_3
PANTRY
}
enum TrashType {
RESTMUELL
BIOMUELL
GELBER_SACK
PAPIER
GLAS
}
enum DocumentCategory {
HAUSORDNUNG
WLAN
VERTRAG
TUTORIAL
SONSTIGES
}
// ----------------------------------------------------------------------------
// USERS
// ----------------------------------------------------------------------------
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
fullName String @map("full_name")
phoneNumber String? @map("phone_number")
role UserRole
avatarUrl String? @map("avatar_url")
pushToken String? @map("push_token") // OneSignal Player ID
// Ein Mieter ist aktuell genau einem Zimmer zugeordnet (Bequemlichkeits-FK,
// die verbindliche Quelle bleibt Contract).
roomId String? @map("room_id")
room Room? @relation("RoomOccupant", fields: [roomId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
contracts Contract[]
createdTickets Ticket[] @relation("TicketCreator")
assignedTickets Ticket[] @relation("TicketAssignee")
cleaningTasks CleaningTask[]
cleaningAbsences CleaningAbsence[]
expensesPaid Expense[]
expenseShares ExpenseShare[]
handoverProtocols HandoverProtocol[]
guestCodesIssued GuestCode[]
notifications Notification[]
invitationsSent Invitation[] @relation("InvitationsSent")
storageSlots StorageSlot[]
documentsUploaded Document[]
utilityStatementsCreated UtilityStatement[]
utilityStatementShares UtilityStatementShare[]
ratingsGiven TenantRating[] @relation("RatingsGiven")
ratingsReceived TenantRating[] @relation("RatingsReceived")
@@map("users")
}
// ----------------------------------------------------------------------------
// ROOMS
// ----------------------------------------------------------------------------
model Room {
id String @id @default(uuid())
roomNumber String @map("room_number") // "Zimmer 1" .. "Zimmer 4"
sizeSqm Decimal @map("size_sqm") @db.Decimal(5, 2) // 20.0 / 16.0
baseRent Decimal @map("base_rent") @db.Decimal(8, 2) // Nettokaltmiete
utilityPauschal Decimal @map("utility_pauschal") @db.Decimal(8, 2)
status RoomStatus @default(VACANT)
floorPlanUrl String? @map("floor_plan_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
occupants User[] @relation("RoomOccupant")
contracts Contract[]
tickets Ticket[]
inventoryItems Inventory[]
meterReadings MeterReading[]
invitations Invitation[]
utilityStatementShares UtilityStatementShare[]
@@map("rooms")
}
// ----------------------------------------------------------------------------
// INVITATIONS (Vermieter lädt Mieter per E-Mail ein)
// ----------------------------------------------------------------------------
model Invitation {
id String @id @default(uuid())
email String
token String @unique
role UserRole @default(TENANT)
roomId String? @map("room_id")
room Room? @relation(fields: [roomId], references: [id])
invitedById String @map("invited_by_id")
invitedBy User @relation("InvitationsSent", fields: [invitedById], references: [id])
status InvitationStatus @default(PENDING)
expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([email])
@@index([status])
@@map("invitations")
}
// ----------------------------------------------------------------------------
// CONTRACTS
// ----------------------------------------------------------------------------
model Contract {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
roomId String @map("room_id")
room Room @relation(fields: [roomId], references: [id])
startDate DateTime @map("start_date")
endDate DateTime? @map("end_date") // NULL = unbefristet
totalWarmRent Decimal @map("total_warm_rent") @db.Decimal(8, 2)
depositAmount Decimal @map("deposit_amount") @db.Decimal(8, 2)
paymentDueDay Int @default(3) @map("payment_due_day") // Fälligkeit: 3. Werktag
// Kündigungsfristen & Anpassungsklauseln
noticePeriodMonths Int @default(3) @map("notice_period_months")
rentAdjustmentType RentAdjustmentType @default(NONE) @map("rent_adjustment_type")
rentAdjustmentClause String? @map("rent_adjustment_clause") @db.Text // Freitext der Klausel
nextAdjustmentDate DateTime? @map("next_adjustment_date")
isActive Boolean @default(true) @map("is_active")
contractPdfUrl String? @map("contract_pdf_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
payments Payment[]
handoverProtocols HandoverProtocol[]
@@index([roomId, isActive])
@@map("contracts")
}
// ----------------------------------------------------------------------------
// PAYMENTS (Banking API Abgleich)
// ----------------------------------------------------------------------------
model Payment {
id String @id @default(uuid())
contractId String @map("contract_id")
contract Contract @relation(fields: [contractId], references: [id])
amount Decimal @db.Decimal(8, 2)
dueDate DateTime @map("due_date")
paidAt DateTime? @map("paid_at")
status PaymentStatus @default(PENDING)
// Banking-API (FinAPI / Nordigen / GoCardless)
bankTransactionId String? @unique @map("bank_transaction_id")
bankReferenceText String? @map("bank_reference_text")
matchedAutomatically Boolean @default(false) @map("matched_automatically")
dunningSentAt DateTime? @map("dunning_sent_at") // Zeitpunkt Mahn-Notification
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([contractId, dueDate])
@@index([status])
@@map("payments")
}
// ----------------------------------------------------------------------------
// TICKETS (Schadensmelder)
// ----------------------------------------------------------------------------
model Ticket {
id String @id @default(uuid())
creatorId String @map("creator_id")
creator User @relation("TicketCreator", fields: [creatorId], references: [id])
assigneeId String? @map("assignee_id") // Handwerker (interner User-Account, optional)
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
craftsmanId String? @map("craftsman_id") // Handwerker-Kontakt (externer Betrieb ohne Login)
craftsman Craftsman? @relation(fields: [craftsmanId], references: [id])
roomId String? @map("room_id") // NULL = Gemeinschaftsbereich
room Room? @relation(fields: [roomId], references: [id])
title String
description String @db.Text
category TicketCategory
priority TicketPriority @default(MEDIUM)
status TicketStatus @default(OPEN)
imageUrls String[] @map("image_urls") // Foto-Beweise
pdfExportUrl String? @map("pdf_export_url") // 1-Klick-PDF für Handwerker
resolvedAt DateTime? @map("resolved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status, priority])
@@map("tickets")
}
// ----------------------------------------------------------------------------
// CLEANING TASKS (Putzplan)
// ----------------------------------------------------------------------------
model CleaningTask {
id String @id @default(uuid())
assignedUserId String @map("assigned_user_id")
assignedUser User @relation(fields: [assignedUserId], references: [id])
area CleaningArea
weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche
dueDate DateTime @map("due_date")
completedAt DateTime? @map("completed_at")
proofImageUrl String? @map("proof_image_url")
status CleaningStatus @default(PENDING)
verifiedBy String? @map("verified_by") // optionale Mitbewohner-Bestätigung
reminderSentAt DateTime? @map("reminder_sent_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([assignedUserId, weekOf])
@@index([status, dueDate])
@@map("cleaning_tasks")
}
// Abwesenheiten im Putzplan-Kalender (Urlaub, "kann nicht putzen" o. Ä.).
// Für alle Mieter sichtbar (gemeinschaftliche Organisationsinfo), aber nur
// vom jeweiligen Mieter selbst oder Vermieter/Admin anlegbar/löschbar.
model CleaningAbsence {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
note String?
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, startDate])
@@map("cleaning_absences")
}
// ----------------------------------------------------------------------------
// INVENTORY (Möbel & Geräte)
// ----------------------------------------------------------------------------
model Inventory {
id String @id @default(uuid())
roomId String? @map("room_id") // NULL = Gemeinschaftsfläche
room Room? @relation(fields: [roomId], references: [id])
itemName String @map("item_name")
purchaseDate DateTime? @map("purchase_date")
purchasePrice Decimal? @map("purchase_price") @db.Decimal(8, 2)
condition ItemCondition @default(GOOD)
manualPdfUrl String? @map("manual_pdf_url") // Anleitung (z.B. Entkalkung)
photoUrls String[] @default([]) @map("photo_urls") // mehrere Fotos möglich
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
handoverItems HandoverItem[]
@@map("inventory")
}
// ----------------------------------------------------------------------------
// EXPENSES (WG-Kasse)
// ----------------------------------------------------------------------------
model Expense {
id String @id @default(uuid())
payerId String @map("payer_id")
payer User @relation(fields: [payerId], references: [id])
amount Decimal @db.Decimal(8, 2)
description String
category ExpenseCategory @default(HAUSHALT)
receiptUrl String? @map("receipt_url")
createdAt DateTime @default(now()) @map("created_at")
shares ExpenseShare[]
@@map("expenses")
}
// Automatischer Split (Standard: durch 4), pro Mieter ein Anteil
model ExpenseShare {
id String @id @default(uuid())
expenseId String @map("expense_id")
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
shareAmount Decimal @map("share_amount") @db.Decimal(8, 2)
settlementStatus SettlementStatus @default(OPEN) @map("settlement_status")
settledAt DateTime? @map("settled_at")
@@unique([expenseId, userId])
@@map("expense_shares")
}
// ----------------------------------------------------------------------------
// HANDOVER PROTOCOLS (Übergabeprotokoll bei Ein-/Auszug)
// ----------------------------------------------------------------------------
model HandoverProtocol {
id String @id @default(uuid())
contractId String @map("contract_id")
contract Contract @relation(fields: [contractId], references: [id])
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
type HandoverType
protocolDate DateTime @map("protocol_date")
signatureUrl String? @map("signature_url") // digitale Unterschrift (Bild/SVG)
pdfUrl String? @map("pdf_url")
createdAt DateTime @default(now()) @map("created_at")
items HandoverItem[]
meterReadings MeterReading[]
@@map("handover_protocols")
}
model HandoverItem {
id String @id @default(uuid())
protocolId String @map("protocol_id")
protocol HandoverProtocol @relation(fields: [protocolId], references: [id], onDelete: Cascade)
inventoryId String? @map("inventory_id")
inventory Inventory? @relation(fields: [inventoryId], references: [id])
condition ItemCondition
note String?
photoUrl String? @map("photo_url")
@@map("handover_items")
}
model MeterReading {
id String @id @default(uuid())
protocolId String? @map("protocol_id")
protocol HandoverProtocol? @relation(fields: [protocolId], references: [id])
roomId String? @map("room_id")
room Room? @relation(fields: [roomId], references: [id])
meterType String @map("meter_type") // STROM, WASSER, GAS
value Decimal @db.Decimal(10, 2)
readAt DateTime @map("read_at")
photoUrl String? @map("photo_url")
@@map("meter_readings")
}
// ----------------------------------------------------------------------------
// SMART LOCK / GUEST CODES
// ----------------------------------------------------------------------------
model GuestCode {
id String @id @default(uuid())
issuedById String @map("issued_by_id")
issuedBy User @relation(fields: [issuedById], references: [id])
code String @unique
lockDeviceId String @map("lock_device_id") // Nuki/Tuya Device-ID
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
status GuestCodeStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
@@map("guest_codes")
}
// ----------------------------------------------------------------------------
// UNMATCHED TRANSACTIONS (Banking-Abgleich ohne eindeutigen Treffer)
// ----------------------------------------------------------------------------
model UnmatchedTransactionLog {
id String @id @default(uuid())
bankTransactionId String @unique @map("bank_transaction_id")
amount Decimal @db.Decimal(8, 2)
remittanceInfo String @map("remittance_info") @db.Text
reason String @db.Text
resolvedPaymentId String? @map("resolved_payment_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("unmatched_transaction_logs")
}
// ----------------------------------------------------------------------------
// NOTIFICATIONS (Push-Log)
// ----------------------------------------------------------------------------
model Notification {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
type String // z.B. "RENT_OVERDUE", "CLEANING_REMINDER", "TRASH_TOMORROW"
title String
body String
payload Json?
sentAt DateTime @default(now()) @map("sent_at")
readAt DateTime? @map("read_at")
@@index([userId, sentAt])
@@map("notifications")
}
// ----------------------------------------------------------------------------
// KÜCHEN-/SCHRANKPLANER & MÜLL-KALENDER
// ----------------------------------------------------------------------------
// Ein einzelnes Fach im Kühlschrank/Gefrierfach/Schrank, das sich ein Mieter
// reservieren ("beanspruchen") kann. NULL userId = frei/gemeinschaftlich.
model StorageSlot {
id String @id @default(uuid())
location StorageLocation
label String // z. B. "Fach 1 oben"
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("storage_slots")
}
// Müll-Kalender: Abholtermine je Mülltyp, für den Vorabend-Push.
model TrashSchedule {
id String @id @default(uuid())
type TrashType
date DateTime
reminderSentAt DateTime? @map("reminder_sent_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([type, date])
@@index([date])
@@map("trash_schedule")
}
// ----------------------------------------------------------------------------
// DOKUMENTEN-SAFE & TUTORIALS
// ----------------------------------------------------------------------------
model Document {
id String @id @default(uuid())
title String
category DocumentCategory @default(SONSTIGES)
url String // Link zur Datei/zum Video (PDF, Bild, YouTube o. Ä.)
description String?
uploadedById String @map("uploaded_by_id")
uploadedBy User @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@map("documents")
}
// ----------------------------------------------------------------------------
// NEBENKOSTENABRECHNUNG (Utility-Statements)
// ----------------------------------------------------------------------------
// Eine Nebenkostenabrechnung für einen Zeitraum (z. B. ein Jahr). Der
// Vermieter trägt den Gesamtbetrag ein; die App verteilt ihn automatisch
// proportional zur Zimmergröße (sizeSqm) auf alle aktuell aktiven Mieter —
// dieselbe faire Grundlage wie bei der Nettokaltmiete.
model UtilityStatement {
id String @id @default(uuid())
periodStart DateTime @map("period_start")
periodEnd DateTime @map("period_end")
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
description String?
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
shares UtilityStatementShare[]
@@map("utility_statements")
}
// Anteil eines einzelnen Mieters an einer Nebenkostenabrechnung. Für den
// betroffenen Mieter selbst sichtbar, für alle anderen Mieter NICHT (private
// Abrechnungsdaten) — nur der Vermieter/Admin sieht alle Anteile.
model UtilityStatementShare {
id String @id @default(uuid())
statementId String @map("statement_id")
statement UtilityStatement @relation(fields: [statementId], references: [id])
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
roomId String? @map("room_id")
room Room? @relation(fields: [roomId], references: [id])
shareAmount Decimal @map("share_amount") @db.Decimal(8, 2)
settlementStatus SettlementStatus @default(OPEN) @map("settlement_status")
settledAt DateTime? @map("settled_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId])
@@index([statementId])
@@map("utility_statement_shares")
}
// ----------------------------------------------------------------------------
// HANDWERKER-KONTAKTE (Anbindung für Schadensmeldungen)
// ----------------------------------------------------------------------------
enum CraftsmanTrade {
SANITAER
ELEKTRIK
HEIZUNG
SCHREINEREI
MALER
SCHLUESSELDIENST
SONSTIGES
}
// Kontaktkarte eines Handwerksbetriebs (kein eigener Login nötig). Kann
// Tickets zugewiesen werden, damit direkt klar ist, wer sich kümmert und wie
// er erreichbar ist.
model Craftsman {
id String @id @default(uuid())
name String
trade CraftsmanTrade @default(SONSTIGES)
phone String?
email String?
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tickets Ticket[]
@@map("craftsmen")
}
// Peer-Bewertungssystem für (Zwischen-)Mieter: jeder Nutzer kann jeden Mieter
// bewerten (z. B. Sauberkeit, Zuverlässigkeit, WG-Verträglichkeit). Bewusst
// freistehend und nicht an einen Contract gekoppelt. Einsehbar nur für
// Vermieter/Admin (siehe ratings.ts) — Mitbewohner sehen keine Bewertungen,
// um WG-interne Konflikte durch offene Kritik zu vermeiden.
model TenantRating {
id String @id @default(uuid())
raterId String @map("rater_id")
rater User @relation("RatingsGiven", fields: [raterId], references: [id])
ratedUserId String @map("rated_user_id")
ratedUser User @relation("RatingsReceived", fields: [ratedUserId], references: [id])
score Int // 1 (sehr schlecht) bis 5 (sehr gut)
comment String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([ratedUserId])
@@map("tenant_ratings")
}