// ============================================================================ // 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 UtilityBillingModel { PAUSCHALE // Nebenkosten sind in der Warmmiete abgegolten ABRECHNUNG // jährliche Nebenkostenabrechnung (siehe UtilityStatement) } 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 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 AccessCodeStatus { ACTIVE 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 // Pflichtangaben bei Einladungsannahme (Mieter): Kontakt, Meldeadresse, // Ausweiskopie beidseitig. Schufa-Auskunft optional, aber falls vorhanden // darf sie laut Vorgabe nicht älter als 3 Monate sein (schufaDocumentDate). firstResidenceAddress String? @map("first_residence_address") // aktueller Erstwohnsitz laut Meldebescheinigung idDocumentFrontUrl String? @map("id_document_front_url") idDocumentBackUrl String? @map("id_document_back_url") schufaDocumentUrl String? @map("schufa_document_url") schufaDocumentDate DateTime? @map("schufa_document_date") // Ausstellungsdatum der Schufa-Auskunft // 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[] accessCodeAssigned AccessCode[] @relation("AccessCodeUser") accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy") 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") contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich) // Baukasten für den generierten Mustermietvertrag (siehe contractDocumentGenerator.ts) wifiIncluded Boolean @default(false) @map("wifi_included") furnished Boolean @default(false) utilityBillingModel UtilityBillingModel @default(PAUSCHALE) @map("utility_billing_model") landlordName String @default("Familie Lombardo") @map("landlord_name") landlordAddress String? @map("landlord_address") // vom Vermieter selbst einzutragen, keine erfundene Anschrift propertyAddress String? @map("property_address") // Anschrift der Wohnung/des Hauses // Digitale Unterschrift des Mieters zum generierten Vertragsdokument tenantSignatureUrl String? @map("tenant_signature_url") // Data-URL des gezeichneten Unterschrift-Bilds signedAt DateTime? @map("signed_at") 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 String 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") } // Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste // Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne // dass ein Code-Deploy nötig ist. model CleaningAreaOption { id String @id @default(uuid()) name String @unique createdAt DateTime @default(now()) @map("created_at") @@map("cleaning_area_options") } // ---------------------------------------------------------------------------- // 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 // ---------------------------------------------------------------------------- // Persönlicher Smart-Lock/Alarmanlagen-Code je Mieter. Anders als ein // klassischer zeitlich begrenzter Gästecode ist das ein dauerhafter Code für // den festen Zugang/Alarm-Ein-/Ausschalten des jeweiligen Mieters — daher // nur vom Vermieter/Admin vergebbar, der Mieter kann seinen eigenen Code nur // einsehen (nicht selbst anlegen oder ändern). model AccessCode { id String @id @default(uuid()) userId String @map("user_id") user User @relation("AccessCodeUser", fields: [userId], references: [id]) assignedById String @map("assigned_by_id") assignedBy User @relation("AccessCodeAssignedBy", fields: [assignedById], references: [id]) code String lockDeviceId String @map("lock_device_id") // Nuki/Tuya/HmIP Device-ID status AccessCodeStatus @default(ACTIVE) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("access_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") }