diff --git a/README.md b/README.md index 0196703..7e9f4a2 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,23 @@ Live-Ziel: `https://mieterportal.hocks.eu`. **Anmeldung erforderlich** – Gruppen: **Verwaltung** und **Mieter** (Registrierung der Mieter per Code auf der Startseite). Demo-Zugänge (Passwort jeweils `0815`): `j.hinterstein` (Verwaltung) · `a.berger` (Mieterin) · Demo-Registrierungscode: `MP-2026-0815`. -Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. Dark-Mode-Umschalter -in der Seitenleiste bzw. Mobilen Kopfzeile. + +## Architektur (v1.3) + +- **Frontend:** React-SPA (statisch, nginx-Container `mp-portal`) +- **API:** Node/Express (`server/`, Container `mp-api`) – Auth, Daten, Rollenrechte, + serverseitige E-Mail-Benachrichtigungen +- **Datenbank:** PostgreSQL 16 (Container `mp-db`, Volume `mp_pgdata`) – zentrale + Datenhaltung für alle Nutzer, kein localStorage mehr (außer Dark-Mode-Theme) +- Sitzung: HttpOnly-Cookie (8 h), Passwörter als SHA-256-Hash ## Entwicklung ```bash npm install -npm run dev # http://localhost:3000 +npm run dev # http://localhost:3000 (proxyt /api an localhost:3001) npm run build # Produktionsbuild nach dist/ -python beispieldaten.py # generiert src/data/generated.json (Seed wählbar) +python beispieldaten.py # generiert server/seed/generated.json (Seed wählbar) ``` Projekt-Kontext für Kimi Work: [kimi.md](kimi.md). @@ -35,9 +42,13 @@ Projekt-Kontext für Kimi Work: [kimi.md](kimi.md). ./deploy.sh ``` -baut das Frontend, lädt das `dist/`-Paket per `pscp` auf den Server (Port 38592, -Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Container -`mp-portal` neu. TLS/Subdomain liefert der Nginx Proxy Manager (npm_default-Netz). +baut das Frontend, lädt Paket (dist + compose + nginx-Template + `server/`) per `pscp` +auf den Server (Port 38592, Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und +startet den Stack neu inkl. API-Image-Build (`docker compose up -d --build`): +`mp-portal` (nginx), `mp-api` (Node-API), `mp-db` (PostgreSQL). TLS/Subdomain liefert +der Nginx Proxy Manager (npm_default-Netz). Voraussetzung auf dem Server: +`portal.env` mit `MAIL_API_TOKEN` + `POSTGRES_PASSWORD` und `.env`-Symlink darauf +(s. kimi.md Kap. 4). Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`. @@ -50,7 +61,7 @@ Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8 Auf dem Server läuft der Stack unter `/opt/mp-mail`. Das Portal ist verkabelt: Der nginx des `mp-portal`-Containers proxiet ausschließlich `POST /api/mail/send` an die Mail-API und authentifiziert sich dabei serverseitig mit dem Token aus -`portal.env` (nur auf dem Server, gitignored). Das Frontend benachrichtigt die +`portal.env` (nur auf dem Server, gitignored). Die **Node-API** benachrichtigt die Verwaltung bei neuen Schadensmeldungen und die jeweilige Gegenstelle bei Nachrichten – Versand fire-and-forget, ein Ausfall der Mail-API beeinträchtigt das Portal nicht. diff --git a/beispieldaten.py b/beispieldaten.py index 39e4262..994a0dc 100644 --- a/beispieldaten.py +++ b/beispieldaten.py @@ -2,15 +2,16 @@ """Hocks Mieterportal – Beispieldaten-Generator. Erzeugt deterministisch die Demo-Daten des Portals und schreibt sie nach -``src/data/generated.json`` (Units + Transaktionen). Die handschriftlichen -Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen) liegen weiterhin in -``src/data/seed.ts``. +``server/seed/generated.json`` (Units + Transaktionen) – die Datenbank wird +daraus beim ersten Start des mp-api-Containers gespeist. Die handschriftlichen +Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen, Benutzer) liegen in +``server/src/seed-data.js``. Aufruf: python beispieldaten.py (Standard-Seed 20261002) python beispieldaten.py 42 (eigener Seed zum Experimentieren) Achtung: Der Demo-Mieter „Anna Berger" (Whg. Hauptstraße 12 Nr. 7) und die -Algorithmen sind synchron zu src/data/seed.ts zu halten. +Algorithmen sind synchron zu server/src/seed-data.js zu halten. """ import json @@ -115,7 +116,7 @@ def main() -> None: "units": units, "transactions": gen_transactions(units), } - out = Path(__file__).parent / "src" / "data" / "generated.json" + out = Path(__file__).parent / "server" / "seed" / "generated.json" out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") print(f"{out} geschrieben: {data['meta']['units']} Wohnungen " f"({data['meta']['rented']} vermietet), {len(data['transactions'])} Buchungen") diff --git a/deploy.sh b/deploy.sh index ce47a08..f3ed373 100644 --- a/deploy.sh +++ b/deploy.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash -# Hocks Mieterportal – Deploy auf hocks.eu (Workflow aus dem Schulportal) +# Hinterstein Mieterportal – Deploy auf hocks.eu (Workflow aus dem Schulportal) # -# 1. npm run build 2. Paket (dist + compose + nginx) per pscp hochladen -# 3. Entpacken nach /opt/mieterportal, Container mp-portal neu starten +# 1. npm run build 2. Paket (dist + compose + nginx + server) hochladen +# 3. Entpacken nach /opt/mieterportal, Container bauen & ersetzen +# 4. Status prüfen (Container, Portal-Hash, API-Health) # -# Voraussetzungen: Pageant mit priv_ionos_hocks_vps.ppk läuft. +# Voraussetzungen: Pageant mit priv_ionos_hocks_vps.ppk läuft, +# portal.env liegt auf dem Server (/opt/mieterportal/portal.env). # Aufruf: ./deploy.sh set -euo pipefail @@ -24,23 +26,28 @@ rm -rf tmp-deploy && mkdir -p tmp-deploy/docker/templates cp -r dist tmp-deploy/dist cp docker-compose.yml tmp-deploy/ cp docker/templates/default.conf.template tmp-deploy/docker/templates/ +cp -r server tmp-deploy/server +rm -rf tmp-deploy/server/node_modules tar -czf mp-dist.tar.gz -C tmp-deploy . rm -rf tmp-deploy "$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \ mp-dist.tar.gz "$SERVER":/tmp/mp-dist.tar.gz rm -f mp-dist.tar.gz -echo "==> 3/4 Auf Server entpacken und Container ersetzen" +echo "==> 3/4 Auf Server entpacken, API-Image bauen, Stack ersetzen" "$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" " mkdir -p $REMOTE_DIR && tar -xzf /tmp/mp-dist.tar.gz -C $REMOTE_DIR && rm -f /tmp/mp-dist.tar.gz && - cd $REMOTE_DIR && docker compose up -d + cd $REMOTE_DIR && docker compose up -d --build " echo "==> 4/4 Status prüfen" +sleep 5 "$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" " - docker ps --filter name=mp-portal --format 'table {{.Names}}\t{{.Status}}' && - curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js' | head -1 + docker ps --filter name=mp- --format 'table {{.Names}}\t{{.Status}}' && + curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js' | head -1 && + curl -s https://mieterportal.hocks.eu/api/healthz " +echo echo "Fertig: https://mieterportal.hocks.eu" diff --git a/docker-compose.yml b/docker-compose.yml index b263313..1f9b833 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,15 @@ -# Hocks Mieterportal – Portal-Container (statisches Frontend hinter NPM) +# Hinterstein Mieterportal – Portal-Stack auf hocks.eu # TLS + Subdomain liefert der Nginx Proxy Manager (npm_default-Netz). -# Deploy: ./deploy.sh baut dist/ lokal und lädt es auf den Server. +# Deploy: ./deploy.sh baut dist/ + API-Image und lädt alles auf den Server. +# +# Dienste: +# mp-portal nginx, statisches dist/ (SPA), proxiet /api/ → mp-api +# und POST /api/mail/send → mp-mail-api (Token aus portal.env) +# mp-api Node-API (Express + PostgreSQL-Client), baut sich aus ./server +# mp-db PostgreSQL 16 (persistiertes Volume mp_pgdata) +# +# portal.env (nur auf dem Server, gitignored) liefert: +# MAIL_API_TOKEN, POSTGRES_PASSWORD services: mp-portal: @@ -13,9 +22,46 @@ services: - ./docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro env_file: - portal.env + depends_on: + - mp-api + networks: + - npm_default + + mp-api: + build: ./server + container_name: mp-api + restart: unless-stopped + environment: + DATABASE_URL: postgres://mp:${POSTGRES_PASSWORD}@mp-db:5432/mieterportal + MAIL_API_URL: http://mp-mail-api:8080 + MAIL_API_TOKEN: ${MAIL_API_TOKEN} + PORT: 3000 + depends_on: + - mp-db + networks: + - npm_default + + mp-db: + image: postgres:16-alpine + container_name: mp-db + restart: unless-stopped + environment: + POSTGRES_USER: mp + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: mieterportal + volumes: + - mp_pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U mp -d mieterportal'] + interval: 10s + timeout: 5s + retries: 5 networks: - npm_default networks: npm_default: external: true + +volumes: + mp_pgdata: diff --git a/docker/templates/default.conf.template b/docker/templates/default.conf.template index c2dffa9..a59e635 100644 --- a/docker/templates/default.conf.template +++ b/docker/templates/default.conf.template @@ -33,6 +33,16 @@ server { proxy_read_timeout 15s; } + # Portal-API (Node/Express + PostgreSQL): Auth, Daten, Benachrichtigungen. + location /api/ { + proxy_pass http://mp-api:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + client_max_body_size 256k; + proxy_read_timeout 30s; + } + # SPA-Fallback für React Router location / { try_files $uri $uri/ /index.html; diff --git a/kimi.md b/kimi.md index 55a9eba..2ff9a56 100644 --- a/kimi.md +++ b/kimi.md @@ -1,6 +1,6 @@ # Kimi-Kontext: Hinterstein Immobilien · Mieterportal (mieterportal.hocks.eu) -> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 14:30 Uhr. +> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 13:00 Uhr. > Bitte bei größeren Änderungen am Projekt aktualisieren. > Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert. @@ -10,15 +10,18 @@ Mieterportal der **Hinterstein Immobilien** (Demo, Inhaber Joseph Hinterstein) f **50 Wohnungen in 3 Häusern** (Hauptstraße 12 · Ringweg 3 · Am Park 5). Analog zum Schulportal: alle Mietangelegenheiten digital an einem Ort. -**Authentifizierung (echtes Login seit v1.1):** Startseite `/login` mit -Login-Pflicht (Route-Guard `RequireAuth` in `App.tsx` leitet unangemeldete Besucher -auf `/login` um). **Gruppen:** `verwaltung` und `mieter`. Mieter können sich über -einen **Registrierungscode** selbst registrieren (`/registrieren`); Codes legt die -Verwaltung in der Benutzerverwaltung an (einmalig nutzbar, Wohnungsbezug). -Passwörter als **SHA-256-Hash** in localStorage, Session 8 h -(Key `mp_benutzer_session`). Demo-Zugänge (Passwort `0815`): -`j.hinterstein` (Verwaltung, Joseph Hinterstein), `a.berger` (Mieterin, -Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-0815`. +**Datenhaltung (seit v1.3):** zentral serverseitig in **PostgreSQL** über eine +**Node-API** (Express) – kein localStorage mehr für Mietdaten. Mehrere Nutzer +sehen denselben Datenstand in Echtzeit; Rechte werden serverseitig erzwungen +(Mieter nur eigene Wohnung, Verwaltung alles). + +**Authentifizierung:** Login-Pflicht auf `/login` (Route-Guard in `App.tsx`). +Gruppen `verwaltung` und `mieter`; Mieter registrieren sich per einmaligem +**Registrierungscode** (`/registrieren`). Passwörter als SHA-256-Hash (im Browser +gerechnet, Vergleich serverseitig), Sitzung als HttpOnly-Cookie `mp_session` +(8 h, Tabelle `sessions`). Demo-Zugänge (Passwort `0815`): `j.hinterstein` +(Verwaltung), `a.berger` (Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter, +Ringweg 3 Whg. 4). Demo-Registrierungscode: `MP-2026-0815`. **Die 6 Module:** @@ -43,12 +46,14 @@ Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-08 | Teil | Technologie | |---|---| | Frontend | React 18 + TypeScript, Vite 7, Tailwind v3.4 + shadcn/ui (Radix), React Router 7 (BrowserRouter), Recharts | -| State | React Context + useReducer, Persistenz in localStorage (`hinterstein-mieterportal-v2`) | -| Daten | `src/data/seed.ts` (handschriftlich) + `src/data/generated.json` (maschinell via `beispieldaten.py`) | -| Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` | +| State | Store-Kontext im Browser, **alle Daten serverseitig** (kein localStorage mehr); Ladezustand `bereit`, Schreibzugriff async | +| API | `server/`: Node 20 + Express + pg, Session-Cookie, Rollenrechte, serverseitige Mail-Benachrichtigungen | +| Datenbank | PostgreSQL 16 (`mp-db`), Schema/Seed in `server/src/db.js` + `seed.js`, Volume `mp_pgdata` | +| Daten | `server/seed/generated.json` (50 Wohnungen + 552 Buchungen via `beispieldaten.py`) + `server/src/seed-data.js` (handschriftlich) | +| Auth | SHA-256-Hash (Browser) + HttpOnly-Cookie-Session (8 h), `src/lib/api.ts`, `src/lib/auth.ts` | | Branding | Kunde: `src/assets/hinterstein/` (logo.png, joseph-hinterstein.jpg, kontakt.jpg); KI-generiert: `src/assets/logo.png`, `hero.jpg`, `modules.jpg` | -| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` | -| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live**, `/opt/mp-mail` auf dem Server; **verkabelt**: Portal-nginx proxyt `POST /api/mail/send` → `mp-mail-api:8080` und injiziert das Token aus `portal.env` (serverseitig, nie im Browser/Repo) | +| Webserver | Docker-Container `mp-portal` (nginx), statisches `dist/`, proxiet `/api/` → `mp-api:3000` | +| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live**, `/opt/mp-mail`; Portal-nginx proxyt `POST /api/mail/send` mit Token aus `portal.env` | | Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) | ## 3. Wichtige Dateien @@ -59,23 +64,26 @@ ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.). | Datei | Inhalt | |---|---| -| `src/store/store.tsx` | Store-Provider, Reducer, Actions (setRole/addTicket/setTicketStatus/addMessage/newThread/markThreadRead), `fmtEUR` | -| `src/data/seed.ts` | Tickets, Dokumente, Nachrichten-Threads, Ankündigungen + Re-Export units/transactions aus generated.json | -| `src/data/generated.json` | 50 Wohnungen + 552 Buchungen, erzeugt von `beispieldaten.py` (Seed 20261002) | -| `beispieldaten.py` | Deterministischer Generator: `python beispieldaten.py [seed]` | -| `src/types/index.ts` | Alle Typen (Unit, Transaction, Ticket, DocumentItem, Thread, Announcement) | -| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle | +| `src/store/store.tsx` | Store-Kontext: lädt `/api/bootstrap`, stellt Collections + `me`/`role`/`meineEinheit` bereit, async-Methoden für alle Schreibaktionen, `fmtEUR` | +| `src/lib/api.ts` | Fetch-Client (same-origin, JSON, `ApiFehler` mit deutscher Meldung) | +| `src/lib/auth.ts` | SHA-256 für Passwörter, `DEMO_PASSWORT` | +| `server/src/index.js` | Express-API: Auth (Login/Logout/me/Register), Bootstrap, Tickets, Threads/Messages, Benutzerverwaltung, Codes, `/api/healthz` | +| `server/src/db.js` | pg-Pool + idempotentes Schema (11 Tabellen, snake_case) | +| `server/src/seed.js` | Idempotenter Seed (läuft nur bei leerer users-Tabelle) | +| `server/src/seed-data.js` | Handschriftliche Demo-Daten (Tickets, Dokumente, Threads, Ankündigungen, Benutzer, Codes) | +| `server/src/mail.js` | Serverseitiger Versand über mp-mail-api (fire-and-forget) | +| `server/seed/generated.json` | 50 Wohnungen + 552 Buchungen, erzeugt von `beispieldaten.py` (Seed 20261002) | +| `beispieldaten.py` | Deterministischer Generator: `python beispieldaten.py [seed]` → `server/seed/generated.json` | +| `src/types/index.ts` | Alle Typen (Unit, Transaction, Ticket, DocumentItem, Thread, Announcement, Benutzer, RegistrierungsCode) | +| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Logout, Ungelesen-Badge, ThemeToggle | | `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) | -| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) | -| `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login-Formular + Registrierung-Link + Demo-Einstiege | -| `src/lib/mail.ts` | E-Mail-Benachrichtigungen: `sendeBenachrichtigung` (fire-and-forget) an `/api/mail/send`, Empfänger-Auflösung (Verwaltung / Mieter je Wohnung) | -| `docker/templates/default.conf.template` | nginx-Site als envsubst-Template; proxiet `POST /api/mail/send` → `mp-mail-api:8080` mit `${MAIL_API_TOKEN}` aus `portal.env` | -| `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes | -| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow | -| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header | -| `docker-compose.yml` | `mp-portal` (nginx + gemountetes dist) im externen Netz `npm_default`; Mail-Stack separat | -| `deploy.sh` | Bewährter Deploy-Workflow (s. Kap. 4) | -| `mail-server/` | Postfix-Relay + schlanke Mail-API (Muster Schulportal, angepasst) | +| `src/lib/theme.ts` | `mp-theme` in localStorage (nur noch Theme!), Init vor erstem Render (main.tsx) | +| `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login + Registrierung + Demo-Einstiege | +| `src/pages/BenutzerVerwaltung.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer anlegen/pflegen, Registrierungscodes erzeugen | +| `docker/templates/default.conf.template` | nginx-Site als envsubst-Template: SPA, `/api/` → mp-api, `POST /api/mail/send` → mp-mail-api mit `${MAIL_API_TOKEN}` | +| `docker-compose.yml` | Stack: `mp-portal` (nginx), `mp-api` (Node, Build ./server), `mp-db` (PostgreSQL 16, Volume mp_pgdata), Netz npm_default | +| `deploy.sh` | Deploy-Workflow: Build, Paket (dist + compose + nginx + server), Upload, `docker compose up -d --build`, Health-Check | +| `mail-server/` | Postfix-Relay + schlanke Mail-API (Muster Schulportal, angepasst) – separater Stack unter /opt/mp-mail | ## 4. Deploy-Workflow (bewährt, aus Schulportal übernommen) @@ -103,10 +111,18 @@ tar -czf mp-dist.tar.gz -C dist . ``` - SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend. -- **`portal.env` auf dem Server wird vom compose als `env_file` gebraucht** - (liefert `MAIL_API_TOKEN` für den nginx-envsubst). Einmalig angelegt: +- **`portal.env` auf dem Server** (gitignored, liegt nur dort): liefert + `MAIL_API_TOKEN` (für nginx-envsubst und mp-api) und `POSTGRES_PASSWORD` + (für mp-db und die DATABASE_URL des mp-api). Einmalig angelegt: `grep '^MAIL_API_TOKEN=' /opt/mp-mail/mail.env > /opt/mieterportal/portal.env` - Liegt nur auf dem Server (gitignored), wird von deploy.sh nicht hochgeladen. + plus zufälliges `POSTGRES_PASSWORD=…`. Wichtig: compose interpoliert + `${VARIABLE}` nur aus einer `.env`-Datei im Projektverzeichnis (nicht aus + `env_file:`!) – daher auf dem Server `/opt/mieterportal/.env` als + **Symlink auf portal.env**: + `cd /opt/mieterportal && ln -sf portal.env .env` +- deploy.sh macht `docker compose up -d --build` (baut das mp-api-Image auf + dem Server, npm ci im Container) und prüft danach Container-Status, + Portal-Hash und `GET /api/healthz`. - **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`. - **Live (seit 02.10.2026):** Container `mp-portal` läuft auf hocks.eu, statisches `dist/` @@ -123,26 +139,25 @@ tar -czf mp-dist.tar.gz -C dist . ## 5. Zugänge & Konfiguration - **Demo-Zugänge (Passwort `0815`):** `j.hinterstein` (Verwaltung), `a.berger` - (Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: - `MP-2026-0815`. Passwörter werden als SHA-256-Hash gespeichert; Store-Reset über - Browser-DevTools (localStorage-Keys `mp_benutzer`, `mp_benutzer_session`, - `hinterstein-mieterportal-v2` löschen). + (Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter, Ringweg 3 Whg. 4). + Demo-Registrierungscode: `MP-2026-0815`. Passwörter liegen als SHA-256-Hash + in der users-Tabelle; Sitzung als HttpOnly-Cookie (Tabelle sessions). + Abmelden reicht zum „Reset" – nichts liegt mehr im Browser-localStorage + (außer dem Dark-Mode-Theme). +- **Verwaltungs-E-Mail:** `axel@hocks.eu` (echtes, zustellbares Postfach, damit + Benachrichtigungen ankommen) – im Portal änderbar. - **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt auf dem Server in `/opt/mp-mail/mail.env` (Vorlage `mail.env.example` im Repo). - **Mail-Stack live (seit 02.10.2026):** Container `mp-mail` + `mp-mail-api` laufen auf hocks.eu unter `/opt/mp-mail` (Relay über `hocks-eu.mail.protection.outlook.com`). Testmail und API-`/send` (mit `X-Api-Token`) verifiziert (status=sent); ohne Token 401. -- **Mail-Benachrichtigungen im Portal (seit 02.10., v1.2):** Der Portal-nginx - (`docker/templates/default.conf.template`, envsubst) proxiet ausschließlich - `POST /api/mail/send` an `http://mp-mail-api:8080/send` und setzt das Token - aus `/opt/mieterportal/portal.env` (chmod 600, **nicht im Repo**, gitignored). - Das Frontend (`src/lib/mail.ts`) sendet fire-and-forget: neue Schadensmeldung → - alle aktiven Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle - (Mieter ↔ Verwaltung, Empfänger über Wohnungsbezug). E2E verifiziert: - POST über `https://mieterportal.hocks.eu/api/mail/send` → `versandt: true` - (Test an axel@hocks.eu); GET wird mit 403 blockiert. Demo-Empfängeradressen - (z. B. joseph@hinterstein-immo.de) sind fiktiv → dort kein Empfang möglich, - echte Adressen in der Benutzerverwaltung pflegen. +- **Mail-Benachrichtigungen (seit v1.2 nginx-Proxy, seit v1.3 serverseitig):** Der + Portal-nginx proxiet `POST /api/mail/send` an `http://mp-mail-api:8080/send` mit + Token aus `portal.env` (403 für alles außer POST). **Versand aus der Node-API** + (`server/src/mail.js`, fire-and-forget): neue Schadensmeldung → alle aktiven + Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle (Mieter ↔ Verwaltung, + Auflösung über Wohnungsbezug). E2E verifiziert: Zustellung an axel@hocks.eu + mit Postfix status=sent (02.10., 10:42 Uhr). ## 6. Konventionen & Besonderheiten @@ -160,8 +175,12 @@ tar -czf mp-dist.tar.gz -C dist . ## 7. Testing - `npm run build` (`tsc -b`) ist die echte Typprüfung; `npx tsc --noEmit` prüft wegen - Solution-tsconfig ggf. nichts. -- Headless-Screenshots (Dark Mode = Systempräferenz): + Solution-tsconfig ggf. nichts. Server-Syntax: `node --check server/src/*.js`. +- API-Livetests mit curl (Cookie-Jar): Login → `/api/auth/me` → `/api/bootstrap` + (Mieter-Sicht zählt kleiner!) → Ticket/Thread schreiben → Postfix-Log auf + Zustellung prüfen (`docker logs mp-mail | grep to=`). +- Headless-Screenshots (Dark Mode = Systempräferenz, `--virtual-time-budget=10000` + wartet auf den Session-Check): `"/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=... --window-size=WxH "URL"` - Screenshots ausgeliefert nach `preview/`. @@ -196,12 +215,28 @@ angeschlossen: nginx-Proxy `/api/mail/send` mit serverseitig injiziertem Token neuen Schadensmeldungen (→ Verwaltung) und Nachrichten/Antworten (→ Gegenstelle). E2E-Versand live verifiziert (versandt: true), GET auf den Endpunkt 403. +**Erledigt 02.10. (v1.3 live):** Serverseitige Datenbank: PostgreSQL 16 (`mp-db`, +Volume `mp_pgdata`) + Node-API (`mp-api`, Express + pg, eigenes Image via +deploy.sh `--build`). Schema 11 Tabellen (idempotent), Seed einmalig aus +`server/seed/generated.json` + `seed-data.js` (Verwaltungs-E-Mail auf echtes +Postfach axel@hocks.eu gesetzt). Frontend komplett auf API umgestellt: +`src/lib/api.ts`, Store lädt `/api/bootstrap`, kein localStorage mehr (außer +Theme), Ladebildschirm während Session-Check. Rollenrechte serverseitig +durchgesetzt und getestet (Mieterin sieht nur hs-7: 12 Buchungen/2 Tickets, +403 auf fremde Unterhaltung; Verwaltung sieht alles: 552 Buchungen/8 Tickets). +Registrierung, Code-Erzeugung, Ticket inkl. Mail-Benachrichtigung (status=sent +an axel@hocks.eu) E2E verifiziert; Testdaten danach bereinigt. deploy.sh baut +das API-Image auf dem Server; `.env`-Symlink auf portal.env für +Compose-Interpolation beachten (POSTGRES_PASSWORD!). + **Offen:** - Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen. -- Demo-Empfängeradressen durch echte ersetzen, sobald bekannt. -- Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage, - Mehrseitigkeit für 50 echte Mietverhältnisse. +- Demo-Empfängeradressen der Mieter (example.de) durch echte ersetzen, sobald bekannt. +- Sicherheit für Produktion: Passwort-Hash serverseitig salten (bcrypt/argon2), CSRF-Token, + Rate-Limiting auf /api/auth/login. +- Mögliche nächste Schritte: Datenbank-Backup-Job (nächtlicher pg_dump), E-Mail-Verifizierung + bei Registrierung, dokumenten-Download mit echten Dateien. ## 9. Sonstiges diff --git a/preview/07-live-api.png b/preview/07-live-api.png new file mode 100644 index 0000000..e1b52bd Binary files /dev/null and b/preview/07-live-api.png differ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..04849e0 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,20 @@ +# Hinterstein Mieterportal – API-Container (Node + PostgreSQL-Client) +# Build: docker compose build mp-api +# Die Datenbank läuft als eigener Service (mp-db) im selben Compose-Projekt. + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev --no-audit --no-fund + +FROM node:20-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src +COPY seed ./seed +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD wget -qO- http://localhost:3000/api/healthz >/dev/null 2>&1 || exit 1 +CMD ["node", "src/index.js"] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..b9ae3bc --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,982 @@ +{ + "name": "mieterportal-api", + "version": "1.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mieterportal-api", + "version": "1.3.0", + "dependencies": { + "express": "^4.21.2", + "pg": "^8.13.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..fe3ec81 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "mieterportal-api", + "version": "1.3.0", + "private": true, + "description": "Node-API + PostgreSQL für das Hinterstein Mieterportal", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "express": "^4.21.2", + "pg": "^8.13.1" + } +} diff --git a/server/seed/generated.json b/server/seed/generated.json new file mode 100644 index 0000000..91edd0e --- /dev/null +++ b/server/seed/generated.json @@ -0,0 +1,6452 @@ +{ + "meta": { + "seed": 20261002, + "units": 50, + "rented": 46 + }, + "units": [ + { + "id": "hs-1", + "building": "Hauptstraße 12", + "number": "1", + "floor": 0, + "size": 51, + "coldRent": 500, + "warmRent": 622, + "tenant": "Anna Berger", + "status": "vermietet" + }, + { + "id": "hs-2", + "building": "Hauptstraße 12", + "number": "2", + "floor": 0, + "size": 58, + "coldRent": 568, + "warmRent": 707, + "tenant": "Jonas Klein", + "status": "vermietet" + }, + { + "id": "hs-3", + "building": "Hauptstraße 12", + "number": "3", + "floor": 0, + "size": 65, + "coldRent": 637, + "warmRent": 828, + "tenant": "Marta Wolf", + "status": "vermietet" + }, + { + "id": "hs-4", + "building": "Hauptstraße 12", + "number": "4", + "floor": 0, + "size": 72, + "coldRent": 706, + "warmRent": 879, + "tenant": "Leon Neumann", + "status": "vermietet" + }, + { + "id": "hs-5", + "building": "Hauptstraße 12", + "number": "5", + "floor": 1, + "size": 79, + "coldRent": 774, + "warmRent": 964, + "tenant": "Sofia Schäfer", + "status": "vermietet" + }, + { + "id": "hs-6", + "building": "Hauptstraße 12", + "number": "6", + "floor": 1, + "size": 86, + "coldRent": 843, + "warmRent": 1084, + "tenant": "Felix Krüger", + "status": "vermietet" + }, + { + "id": "hs-7", + "building": "Hauptstraße 12", + "number": "7", + "floor": 1, + "size": 93, + "coldRent": 911, + "warmRent": 1134, + "tenant": "Anna Berger", + "status": "vermietet" + }, + { + "id": "hs-8", + "building": "Hauptstraße 12", + "number": "8", + "floor": 1, + "size": 100, + "coldRent": 980, + "warmRent": 1220, + "tenant": "Lena Brandt", + "status": "vermietet" + }, + { + "id": "hs-9", + "building": "Hauptstraße 12", + "number": "9", + "floor": 2, + "size": 107, + "coldRent": 1049, + "warmRent": 1341, + "tenant": null, + "status": "frei" + }, + { + "id": "hs-10", + "building": "Hauptstraße 12", + "number": "10", + "floor": 2, + "size": 114, + "coldRent": 1117, + "warmRent": 1391, + "tenant": "Paul Vogel", + "status": "vermietet" + }, + { + "id": "hs-11", + "building": "Hauptstraße 12", + "number": "11", + "floor": 2, + "size": 43, + "coldRent": 421, + "warmRent": 524, + "tenant": "Emma Otto", + "status": "vermietet" + }, + { + "id": "hs-12", + "building": "Hauptstraße 12", + "number": "12", + "floor": 2, + "size": 50, + "coldRent": 490, + "warmRent": 645, + "tenant": "Noah Horn", + "status": "vermietet" + }, + { + "id": "hs-13", + "building": "Hauptstraße 12", + "number": "13", + "floor": 3, + "size": 57, + "coldRent": 559, + "warmRent": 696, + "tenant": "Clara Busch", + "status": "vermietet" + }, + { + "id": "hs-14", + "building": "Hauptstraße 12", + "number": "14", + "floor": 3, + "size": 64, + "coldRent": 627, + "warmRent": 781, + "tenant": "Ben Thomas", + "status": "vermietet" + }, + { + "id": "hs-15", + "building": "Hauptstraße 12", + "number": "15", + "floor": 3, + "size": 71, + "coldRent": 696, + "warmRent": 901, + "tenant": "Nora Pohl", + "status": "vermietet" + }, + { + "id": "hs-16", + "building": "Hauptstraße 12", + "number": "16", + "floor": 3, + "size": 78, + "coldRent": 764, + "warmRent": 951, + "tenant": "Max Sander", + "status": "vermietet" + }, + { + "id": "hs-17", + "building": "Hauptstraße 12", + "number": "17", + "floor": 4, + "size": 85, + "coldRent": 833, + "warmRent": 1037, + "tenant": "Julia Lindner", + "status": "vermietet" + }, + { + "id": "hs-18", + "building": "Hauptstraße 12", + "number": "18", + "floor": 4, + "size": 92, + "coldRent": 902, + "warmRent": 1158, + "tenant": null, + "status": "frei" + }, + { + "id": "hs-19", + "building": "Hauptstraße 12", + "number": "19", + "floor": 4, + "size": 99, + "coldRent": 970, + "warmRent": 1208, + "tenant": "Tim Berger", + "status": "vermietet" + }, + { + "id": "hs-20", + "building": "Hauptstraße 12", + "number": "20", + "floor": 4, + "size": 106, + "coldRent": 1039, + "warmRent": 1293, + "tenant": "Sarah Klein", + "status": "vermietet" + }, + { + "id": "rw-1", + "building": "Ringweg 3", + "number": "1", + "floor": 0, + "size": 51, + "coldRent": 454, + "warmRent": 576, + "tenant": "Finn Wolf", + "status": "vermietet" + }, + { + "id": "rw-2", + "building": "Ringweg 3", + "number": "2", + "floor": 0, + "size": 58, + "coldRent": 516, + "warmRent": 655, + "tenant": "Laura Neumann", + "status": "vermietet" + }, + { + "id": "rw-3", + "building": "Ringweg 3", + "number": "3", + "floor": 0, + "size": 65, + "coldRent": 578, + "warmRent": 769, + "tenant": "Jan Schäfer", + "status": "vermietet" + }, + { + "id": "rw-4", + "building": "Ringweg 3", + "number": "4", + "floor": 0, + "size": 72, + "coldRent": 641, + "warmRent": 814, + "tenant": "Mia Krüger", + "status": "vermietet" + }, + { + "id": "rw-5", + "building": "Ringweg 3", + "number": "5", + "floor": 1, + "size": 79, + "coldRent": 703, + "warmRent": 893, + "tenant": "Tom Brandt", + "status": "vermietet" + }, + { + "id": "rw-6", + "building": "Ringweg 3", + "number": "6", + "floor": 1, + "size": 86, + "coldRent": 765, + "warmRent": 1006, + "tenant": "Ela Vogel", + "status": "vermietet" + }, + { + "id": "rw-7", + "building": "Ringweg 3", + "number": "7", + "floor": 1, + "size": 93, + "coldRent": 828, + "warmRent": 1051, + "tenant": "Kim Otto", + "status": "vermietet" + }, + { + "id": "rw-8", + "building": "Ringweg 3", + "number": "8", + "floor": 1, + "size": 100, + "coldRent": 890, + "warmRent": 1130, + "tenant": "Anna Horn", + "status": "vermietet" + }, + { + "id": "rw-9", + "building": "Ringweg 3", + "number": "9", + "floor": 2, + "size": 107, + "coldRent": 952, + "warmRent": 1244, + "tenant": null, + "status": "frei" + }, + { + "id": "rw-10", + "building": "Ringweg 3", + "number": "10", + "floor": 2, + "size": 114, + "coldRent": 1015, + "warmRent": 1289, + "tenant": "Jonas Busch", + "status": "vermietet" + }, + { + "id": "rw-11", + "building": "Ringweg 3", + "number": "11", + "floor": 2, + "size": 43, + "coldRent": 383, + "warmRent": 486, + "tenant": "Marta Thomas", + "status": "vermietet" + }, + { + "id": "rw-12", + "building": "Ringweg 3", + "number": "12", + "floor": 2, + "size": 50, + "coldRent": 445, + "warmRent": 600, + "tenant": "Leon Pohl", + "status": "vermietet" + }, + { + "id": "rw-13", + "building": "Ringweg 3", + "number": "13", + "floor": 3, + "size": 57, + "coldRent": 507, + "warmRent": 644, + "tenant": "Sofia Sander", + "status": "vermietet" + }, + { + "id": "rw-14", + "building": "Ringweg 3", + "number": "14", + "floor": 3, + "size": 64, + "coldRent": 570, + "warmRent": 724, + "tenant": "Felix Lindner", + "status": "vermietet" + }, + { + "id": "rw-15", + "building": "Ringweg 3", + "number": "15", + "floor": 3, + "size": 71, + "coldRent": 632, + "warmRent": 837, + "tenant": "Lena Berger", + "status": "vermietet" + }, + { + "id": "ap-1", + "building": "Am Park 5", + "number": "1", + "floor": 0, + "size": 51, + "coldRent": 530, + "warmRent": 652, + "tenant": "Paul Klein", + "status": "vermietet" + }, + { + "id": "ap-2", + "building": "Am Park 5", + "number": "2", + "floor": 0, + "size": 58, + "coldRent": 603, + "warmRent": 742, + "tenant": "Emma Wolf", + "status": "vermietet" + }, + { + "id": "ap-3", + "building": "Am Park 5", + "number": "3", + "floor": 0, + "size": 65, + "coldRent": 676, + "warmRent": 867, + "tenant": "Noah Neumann", + "status": "vermietet" + }, + { + "id": "ap-4", + "building": "Am Park 5", + "number": "4", + "floor": 0, + "size": 72, + "coldRent": 749, + "warmRent": 922, + "tenant": "Clara Schäfer", + "status": "vermietet" + }, + { + "id": "ap-5", + "building": "Am Park 5", + "number": "5", + "floor": 1, + "size": 79, + "coldRent": 822, + "warmRent": 1012, + "tenant": "Ben Krüger", + "status": "vermietet" + }, + { + "id": "ap-6", + "building": "Am Park 5", + "number": "6", + "floor": 1, + "size": 86, + "coldRent": 894, + "warmRent": 1135, + "tenant": "Nora Brandt", + "status": "vermietet" + }, + { + "id": "ap-7", + "building": "Am Park 5", + "number": "7", + "floor": 1, + "size": 93, + "coldRent": 967, + "warmRent": 1190, + "tenant": "Max Vogel", + "status": "vermietet" + }, + { + "id": "ap-8", + "building": "Am Park 5", + "number": "8", + "floor": 1, + "size": 100, + "coldRent": 1040, + "warmRent": 1280, + "tenant": "Julia Otto", + "status": "vermietet" + }, + { + "id": "ap-9", + "building": "Am Park 5", + "number": "9", + "floor": 2, + "size": 107, + "coldRent": 1113, + "warmRent": 1405, + "tenant": null, + "status": "frei" + }, + { + "id": "ap-10", + "building": "Am Park 5", + "number": "10", + "floor": 2, + "size": 114, + "coldRent": 1186, + "warmRent": 1460, + "tenant": "Tim Horn", + "status": "vermietet" + }, + { + "id": "ap-11", + "building": "Am Park 5", + "number": "11", + "floor": 2, + "size": 43, + "coldRent": 447, + "warmRent": 550, + "tenant": "Sarah Busch", + "status": "vermietet" + }, + { + "id": "ap-12", + "building": "Am Park 5", + "number": "12", + "floor": 2, + "size": 50, + "coldRent": 520, + "warmRent": 675, + "tenant": "Finn Thomas", + "status": "vermietet" + }, + { + "id": "ap-13", + "building": "Am Park 5", + "number": "13", + "floor": 3, + "size": 57, + "coldRent": 593, + "warmRent": 730, + "tenant": "Laura Pohl", + "status": "vermietet" + }, + { + "id": "ap-14", + "building": "Am Park 5", + "number": "14", + "floor": 3, + "size": 64, + "coldRent": 666, + "warmRent": 820, + "tenant": "Jan Sander", + "status": "vermietet" + }, + { + "id": "ap-15", + "building": "Am Park 5", + "number": "15", + "floor": 3, + "size": 71, + "coldRent": 738, + "warmRent": 943, + "tenant": "Mia Lindner", + "status": "vermietet" + } + ], + "transactions": [ + { + "id": "tx-hs-1-0", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Januar", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-1-1", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Februar", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-1-2", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "März", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-1-3", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "April", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-1-4", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Mai", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-1-5", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Juni", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-1-6", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Juli", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-1-7", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "August", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-1-8", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "September", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-1-9", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Oktober", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-1-10", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "November", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-1-11", + "unitId": "hs-1", + "tenant": "Anna Berger", + "month": "Dezember", + "year": 2026, + "amount": 622, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-2-0", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Januar", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-2-1", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Februar", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-2-2", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "März", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-2-3", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "April", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-2-4", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Mai", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-2-5", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Juni", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-2-6", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Juli", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-2-7", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "August", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-2-8", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "September", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-2-9", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Oktober", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-2-10", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "November", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-2-11", + "unitId": "hs-2", + "tenant": "Jonas Klein", + "month": "Dezember", + "year": 2026, + "amount": 707, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-3-0", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Januar", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-3-1", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Februar", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-3-2", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "März", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-3-3", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "April", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-3-4", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Mai", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-3-5", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Juni", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-3-6", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Juli", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-3-7", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "August", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-3-8", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "September", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-3-9", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Oktober", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-3-10", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "November", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-3-11", + "unitId": "hs-3", + "tenant": "Marta Wolf", + "month": "Dezember", + "year": 2026, + "amount": 828, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-4-0", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Januar", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-4-1", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Februar", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-4-2", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "März", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-4-3", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "April", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-4-4", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Mai", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-4-5", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Juni", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-4-6", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Juli", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-4-7", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "August", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-4-8", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "September", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-4-9", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Oktober", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-4-10", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "November", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-4-11", + "unitId": "hs-4", + "tenant": "Leon Neumann", + "month": "Dezember", + "year": 2026, + "amount": 879, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-5-0", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Januar", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-5-1", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Februar", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-5-2", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "März", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-5-3", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "April", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-5-4", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Mai", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-5-5", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Juni", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-5-6", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Juli", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-5-7", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "August", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-5-8", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "September", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-5-9", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Oktober", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-5-10", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "November", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-5-11", + "unitId": "hs-5", + "tenant": "Sofia Schäfer", + "month": "Dezember", + "year": 2026, + "amount": 964, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-6-0", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Januar", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-6-1", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Februar", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-6-2", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "März", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-6-3", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "April", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-6-4", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Mai", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-6-5", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Juni", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-6-6", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Juli", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-6-7", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "August", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-6-8", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "September", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-6-9", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Oktober", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-6-10", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "November", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-6-11", + "unitId": "hs-6", + "tenant": "Felix Krüger", + "month": "Dezember", + "year": 2026, + "amount": 1084, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-7-0", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Januar", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-7-1", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Februar", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-7-2", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "März", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-7-3", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "April", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-7-4", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Mai", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-7-5", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Juni", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-7-6", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Juli", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-7-7", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "August", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-7-8", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "September", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-7-9", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Oktober", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-7-10", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "November", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-7-11", + "unitId": "hs-7", + "tenant": "Anna Berger", + "month": "Dezember", + "year": 2026, + "amount": 1134, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-8-0", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Januar", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-8-1", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Februar", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-8-2", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "März", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-8-3", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "April", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-8-4", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Mai", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-8-5", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Juni", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-8-6", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Juli", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-8-7", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "August", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-8-8", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "September", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-8-9", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Oktober", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-8-10", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "November", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-8-11", + "unitId": "hs-8", + "tenant": "Lena Brandt", + "month": "Dezember", + "year": 2026, + "amount": 1220, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-10-0", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Januar", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-10-1", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Februar", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-10-2", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "März", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-10-3", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "April", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-10-4", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Mai", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-10-5", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Juni", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-10-6", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Juli", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-10-7", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "August", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-10-8", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "September", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-10-9", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Oktober", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-10-10", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "November", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-10-11", + "unitId": "hs-10", + "tenant": "Paul Vogel", + "month": "Dezember", + "year": 2026, + "amount": 1391, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-11-0", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Januar", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-11-1", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Februar", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-11-2", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "März", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-11-3", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "April", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-11-4", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Mai", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-11-5", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Juni", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-11-6", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Juli", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-11-7", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "August", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-11-8", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "September", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-11-9", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Oktober", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-11-10", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "November", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-11-11", + "unitId": "hs-11", + "tenant": "Emma Otto", + "month": "Dezember", + "year": 2026, + "amount": 524, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-12-0", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Januar", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-12-1", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Februar", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-12-2", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "März", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-12-3", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "April", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-12-4", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Mai", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-12-5", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Juni", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-12-6", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Juli", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-12-7", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "August", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-12-8", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "September", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-12-9", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Oktober", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-12-10", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "November", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-12-11", + "unitId": "hs-12", + "tenant": "Noah Horn", + "month": "Dezember", + "year": 2026, + "amount": 645, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-13-0", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Januar", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-13-1", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Februar", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-13-2", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "März", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-13-3", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "April", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-13-4", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Mai", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-13-5", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Juni", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-13-6", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Juli", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-13-7", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "August", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-13-8", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "September", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-13-9", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Oktober", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-13-10", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "November", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-13-11", + "unitId": "hs-13", + "tenant": "Clara Busch", + "month": "Dezember", + "year": 2026, + "amount": 696, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-14-0", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Januar", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-14-1", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Februar", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-14-2", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "März", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-14-3", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "April", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-14-4", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Mai", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-14-5", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Juni", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-14-6", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Juli", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-14-7", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "August", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-14-8", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "September", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-14-9", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Oktober", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-14-10", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "November", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-14-11", + "unitId": "hs-14", + "tenant": "Ben Thomas", + "month": "Dezember", + "year": 2026, + "amount": 781, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-15-0", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Januar", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-15-1", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Februar", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-15-2", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "März", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-15-3", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "April", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-15-4", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Mai", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-15-5", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Juni", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-15-6", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Juli", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-15-7", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "August", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-15-8", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "September", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-15-9", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Oktober", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-15-10", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "November", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-15-11", + "unitId": "hs-15", + "tenant": "Nora Pohl", + "month": "Dezember", + "year": 2026, + "amount": 901, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-16-0", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Januar", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-16-1", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Februar", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-16-2", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "März", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-16-3", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "April", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-16-4", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Mai", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-16-5", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Juni", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-16-6", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Juli", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-16-7", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "August", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-16-8", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "September", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-16-9", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Oktober", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-16-10", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "November", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-16-11", + "unitId": "hs-16", + "tenant": "Max Sander", + "month": "Dezember", + "year": 2026, + "amount": 951, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-17-0", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Januar", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-17-1", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Februar", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-17-2", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "März", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-17-3", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "April", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-17-4", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Mai", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-17-5", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Juni", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-17-6", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Juli", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-17-7", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "August", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-17-8", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "September", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-17-9", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Oktober", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-17-10", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "November", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-17-11", + "unitId": "hs-17", + "tenant": "Julia Lindner", + "month": "Dezember", + "year": 2026, + "amount": 1037, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-19-0", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Januar", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-19-1", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Februar", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-19-2", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "März", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-19-3", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "April", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-19-4", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Mai", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-19-5", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Juni", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-hs-19-6", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Juli", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-19-7", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "August", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-19-8", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "September", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-19-9", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Oktober", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-19-10", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "November", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-19-11", + "unitId": "hs-19", + "tenant": "Tim Berger", + "month": "Dezember", + "year": 2026, + "amount": 1208, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-20-0", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Januar", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-hs-20-1", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Februar", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-hs-20-2", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "März", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-hs-20-3", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "April", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-hs-20-4", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Mai", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-hs-20-5", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Juni", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-hs-20-6", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Juli", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-hs-20-7", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "August", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-hs-20-8", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "September", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-hs-20-9", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Oktober", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-20-10", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "November", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-hs-20-11", + "unitId": "hs-20", + "tenant": "Sarah Klein", + "month": "Dezember", + "year": 2026, + "amount": 1293, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-1-0", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Januar", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-1-1", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Februar", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-1-2", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "März", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-1-3", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "April", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-1-4", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Mai", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-1-5", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Juni", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-1-6", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Juli", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-1-7", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "August", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-1-8", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "September", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-1-9", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Oktober", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-1-10", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "November", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-1-11", + "unitId": "rw-1", + "tenant": "Finn Wolf", + "month": "Dezember", + "year": 2026, + "amount": 576, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-2-0", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Januar", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-2-1", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Februar", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-2-2", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "März", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-2-3", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "April", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-2-4", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Mai", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-2-5", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Juni", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-2-6", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Juli", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-2-7", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "August", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-2-8", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "September", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-2-9", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Oktober", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-2-10", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "November", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-2-11", + "unitId": "rw-2", + "tenant": "Laura Neumann", + "month": "Dezember", + "year": 2026, + "amount": 655, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-3-0", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Januar", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-3-1", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Februar", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-3-2", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "März", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-3-3", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "April", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-3-4", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Mai", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-3-5", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Juni", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-3-6", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Juli", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-3-7", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "August", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-3-8", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "September", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-3-9", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Oktober", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-3-10", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "November", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-3-11", + "unitId": "rw-3", + "tenant": "Jan Schäfer", + "month": "Dezember", + "year": 2026, + "amount": 769, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-4-0", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Januar", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-4-1", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Februar", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-4-2", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "März", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-4-3", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "April", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-4-4", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Mai", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-4-5", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Juni", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-4-6", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Juli", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-4-7", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "August", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-4-8", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "September", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-4-9", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Oktober", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-4-10", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "November", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-4-11", + "unitId": "rw-4", + "tenant": "Mia Krüger", + "month": "Dezember", + "year": 2026, + "amount": 814, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-5-0", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Januar", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-5-1", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Februar", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-5-2", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "März", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-5-3", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "April", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-5-4", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Mai", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-5-5", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Juni", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-5-6", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Juli", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-5-7", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "August", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-5-8", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "September", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-5-9", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Oktober", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-5-10", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "November", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-5-11", + "unitId": "rw-5", + "tenant": "Tom Brandt", + "month": "Dezember", + "year": 2026, + "amount": 893, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-6-0", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Januar", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-6-1", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Februar", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-6-2", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "März", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-6-3", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "April", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-6-4", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Mai", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-6-5", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Juni", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-6-6", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Juli", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-6-7", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "August", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-6-8", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "September", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-6-9", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Oktober", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-6-10", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "November", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-6-11", + "unitId": "rw-6", + "tenant": "Ela Vogel", + "month": "Dezember", + "year": 2026, + "amount": 1006, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-7-0", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Januar", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-7-1", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Februar", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-7-2", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "März", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-7-3", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "April", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-7-4", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Mai", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-7-5", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Juni", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-7-6", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Juli", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-7-7", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "August", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-7-8", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "September", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-7-9", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Oktober", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-7-10", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "November", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-7-11", + "unitId": "rw-7", + "tenant": "Kim Otto", + "month": "Dezember", + "year": 2026, + "amount": 1051, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-8-0", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Januar", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-8-1", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Februar", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-8-2", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "März", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-8-3", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "April", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-8-4", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Mai", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-8-5", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Juni", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-8-6", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Juli", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-rw-8-7", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "August", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-8-8", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "September", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-8-9", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Oktober", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-8-10", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "November", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-8-11", + "unitId": "rw-8", + "tenant": "Anna Horn", + "month": "Dezember", + "year": 2026, + "amount": 1130, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-10-0", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Januar", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-10-1", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Februar", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-10-2", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "März", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-10-3", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "April", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-10-4", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Mai", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-10-5", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Juni", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-10-6", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Juli", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-10-7", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "August", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-10-8", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "September", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-10-9", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Oktober", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-10-10", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "November", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-10-11", + "unitId": "rw-10", + "tenant": "Jonas Busch", + "month": "Dezember", + "year": 2026, + "amount": 1289, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-11-0", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Januar", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-11-1", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Februar", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-11-2", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "März", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-11-3", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "April", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-11-4", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Mai", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-11-5", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Juni", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-11-6", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Juli", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-11-7", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "August", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-11-8", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "September", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-11-9", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Oktober", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-11-10", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "November", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-11-11", + "unitId": "rw-11", + "tenant": "Marta Thomas", + "month": "Dezember", + "year": 2026, + "amount": 486, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-12-0", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Januar", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-12-1", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Februar", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-12-2", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "März", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-12-3", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "April", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-12-4", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Mai", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-12-5", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Juni", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-12-6", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Juli", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-12-7", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "August", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-12-8", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "September", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-12-9", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Oktober", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-12-10", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "November", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-12-11", + "unitId": "rw-12", + "tenant": "Leon Pohl", + "month": "Dezember", + "year": 2026, + "amount": 600, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-13-0", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Januar", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-13-1", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Februar", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-13-2", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "März", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-13-3", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "April", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-13-4", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Mai", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-13-5", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Juni", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-13-6", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Juli", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-13-7", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "August", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-13-8", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "September", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-13-9", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Oktober", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-13-10", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "November", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-13-11", + "unitId": "rw-13", + "tenant": "Sofia Sander", + "month": "Dezember", + "year": 2026, + "amount": 644, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-14-0", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Januar", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-14-1", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Februar", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-14-2", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "März", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-14-3", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "April", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-14-4", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Mai", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-14-5", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Juni", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-14-6", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Juli", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-14-7", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "August", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-14-8", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "September", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-14-9", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Oktober", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-14-10", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "November", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-14-11", + "unitId": "rw-14", + "tenant": "Felix Lindner", + "month": "Dezember", + "year": 2026, + "amount": 724, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-15-0", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Januar", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-rw-15-1", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Februar", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-rw-15-2", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "März", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-rw-15-3", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "April", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-rw-15-4", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Mai", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-rw-15-5", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Juni", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-rw-15-6", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Juli", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-rw-15-7", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "August", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-rw-15-8", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "September", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-rw-15-9", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Oktober", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-15-10", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "November", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-rw-15-11", + "unitId": "rw-15", + "tenant": "Lena Berger", + "month": "Dezember", + "year": 2026, + "amount": 837, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-1-0", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Januar", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-1-1", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Februar", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-1-2", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "März", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-1-3", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "April", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-1-4", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Mai", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-1-5", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Juni", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-1-6", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Juli", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-1-7", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "August", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-1-8", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "September", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-1-9", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Oktober", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-1-10", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "November", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-1-11", + "unitId": "ap-1", + "tenant": "Paul Klein", + "month": "Dezember", + "year": 2026, + "amount": 652, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-2-0", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Januar", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-2-1", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Februar", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-2-2", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "März", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-2-3", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "April", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-2-4", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Mai", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-2-5", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Juni", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-2-6", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Juli", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-2-7", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "August", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-2-8", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "September", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-2-9", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Oktober", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-2-10", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "November", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-2-11", + "unitId": "ap-2", + "tenant": "Emma Wolf", + "month": "Dezember", + "year": 2026, + "amount": 742, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-3-0", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Januar", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-3-1", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Februar", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-3-2", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "März", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-3-3", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "April", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-3-4", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Mai", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-3-5", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Juni", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-3-6", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Juli", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-3-7", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "August", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-3-8", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "September", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-3-9", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Oktober", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-3-10", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "November", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-3-11", + "unitId": "ap-3", + "tenant": "Noah Neumann", + "month": "Dezember", + "year": 2026, + "amount": 867, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-4-0", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Januar", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-4-1", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Februar", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-4-2", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "März", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-4-3", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "April", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-4-4", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Mai", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-4-5", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Juni", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-4-6", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Juli", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-4-7", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "August", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-4-8", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "September", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-4-9", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Oktober", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-4-10", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "November", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-4-11", + "unitId": "ap-4", + "tenant": "Clara Schäfer", + "month": "Dezember", + "year": 2026, + "amount": 922, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-5-0", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Januar", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-5-1", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Februar", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-5-2", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "März", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-5-3", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "April", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-5-4", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Mai", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-5-5", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Juni", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-5-6", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Juli", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-5-7", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "August", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-5-8", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "September", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-5-9", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Oktober", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-5-10", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "November", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-5-11", + "unitId": "ap-5", + "tenant": "Ben Krüger", + "month": "Dezember", + "year": 2026, + "amount": 1012, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-6-0", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Januar", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-6-1", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Februar", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-6-2", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "März", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-6-3", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "April", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-6-4", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Mai", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-6-5", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Juni", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-6-6", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Juli", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-6-7", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "August", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-6-8", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "September", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-6-9", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Oktober", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-6-10", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "November", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-6-11", + "unitId": "ap-6", + "tenant": "Nora Brandt", + "month": "Dezember", + "year": 2026, + "amount": 1135, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-7-0", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Januar", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-7-1", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Februar", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-7-2", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "März", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-7-3", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "April", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-7-4", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Mai", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-7-5", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Juni", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-7-6", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Juli", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-7-7", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "August", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-7-8", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "September", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-7-9", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Oktober", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-7-10", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "November", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-7-11", + "unitId": "ap-7", + "tenant": "Max Vogel", + "month": "Dezember", + "year": 2026, + "amount": 1190, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-8-0", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Januar", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-8-1", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Februar", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-8-2", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "März", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-8-3", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "April", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-8-4", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Mai", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-8-5", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Juni", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-8-6", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Juli", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "07.07.2026" + }, + { + "id": "tx-ap-8-7", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "August", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-8-8", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "September", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-8-9", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Oktober", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-8-10", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "November", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-8-11", + "unitId": "ap-8", + "tenant": "Julia Otto", + "month": "Dezember", + "year": 2026, + "amount": 1280, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-10-0", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Januar", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-10-1", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Februar", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-10-2", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "März", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-10-3", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "April", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-10-4", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Mai", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-10-5", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Juni", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-10-6", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Juli", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-10-7", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "August", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-10-8", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "September", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-10-9", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Oktober", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-10-10", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "November", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-10-11", + "unitId": "ap-10", + "tenant": "Tim Horn", + "month": "Dezember", + "year": 2026, + "amount": 1460, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-11-0", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Januar", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-11-1", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Februar", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-11-2", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "März", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-11-3", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "April", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-11-4", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Mai", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-11-5", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Juni", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-11-6", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Juli", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-11-7", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "August", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-11-8", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "September", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-11-9", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Oktober", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-11-10", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "November", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-11-11", + "unitId": "ap-11", + "tenant": "Sarah Busch", + "month": "Dezember", + "year": 2026, + "amount": 550, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-12-0", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Januar", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-12-1", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Februar", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-12-2", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "März", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-12-3", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "April", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-12-4", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Mai", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-12-5", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Juni", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-12-6", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Juli", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-12-7", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "August", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-12-8", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "September", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-12-9", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Oktober", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-12-10", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "November", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-12-11", + "unitId": "ap-12", + "tenant": "Finn Thomas", + "month": "Dezember", + "year": 2026, + "amount": 675, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-13-0", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Januar", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-13-1", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Februar", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-13-2", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "März", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-13-3", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "April", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-13-4", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Mai", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-13-5", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Juni", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-13-6", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Juli", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-13-7", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "August", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-13-8", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "September", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-13-9", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Oktober", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-13-10", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "November", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-13-11", + "unitId": "ap-13", + "tenant": "Laura Pohl", + "month": "Dezember", + "year": 2026, + "amount": 730, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-14-0", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Januar", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-14-1", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Februar", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-14-2", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "März", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-14-3", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "April", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-14-4", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Mai", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-14-5", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Juni", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-14-6", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Juli", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-14-7", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "August", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-14-8", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "September", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-14-9", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Oktober", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-14-10", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "November", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-14-11", + "unitId": "ap-14", + "tenant": "Jan Sander", + "month": "Dezember", + "year": 2026, + "amount": 820, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-15-0", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Januar", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.01.2026" + }, + { + "id": "tx-ap-15-1", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Februar", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "02.02.2026" + }, + { + "id": "tx-ap-15-2", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "März", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "03.03.2026" + }, + { + "id": "tx-ap-15-3", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "April", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "04.04.2026" + }, + { + "id": "tx-ap-15-4", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Mai", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "05.05.2026" + }, + { + "id": "tx-ap-15-5", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Juni", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "06.06.2026" + }, + { + "id": "tx-ap-15-6", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Juli", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "überfällig" + }, + { + "id": "tx-ap-15-7", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "August", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "08.08.2026" + }, + { + "id": "tx-ap-15-8", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "September", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "bezahlt", + "paidAt": "01.09.2026" + }, + { + "id": "tx-ap-15-9", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Oktober", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-15-10", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "November", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "offen" + }, + { + "id": "tx-ap-15-11", + "unitId": "ap-15", + "tenant": "Mia Lindner", + "month": "Dezember", + "year": 2026, + "amount": 943, + "type": "Miete", + "status": "offen" + } + ] +} \ No newline at end of file diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..6b8f45a --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,129 @@ +/** PostgreSQL-Verbindung + Schema für das Mieterportal. + * + * Tabellen werden beim Start idempotent angelegt (CREATE TABLE IF NOT EXISTS), + * danach lädt seed.js die Demo-Daten, falls die Datenbank leer ist. + */ + +const { Pool } = require('pg') + +const pool = new Pool({ + connectionString: + process.env.DATABASE_URL || 'postgres://mp:mp@localhost:5432/mieterportal', + max: 10, +}) + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS units ( + id TEXT PRIMARY KEY, + building TEXT NOT NULL, + number TEXT NOT NULL, + floor INT NOT NULL, + size NUMERIC(8,2) NOT NULL, + cold_rent NUMERIC(10,2) NOT NULL, + warm_rent NUMERIC(10,2) NOT NULL, + tenant TEXT, + status TEXT NOT NULL CHECK (status IN ('vermietet','frei')) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL REFERENCES units(id), + tenant TEXT NOT NULL, + month TEXT NOT NULL, + year INT NOT NULL, + amount NUMERIC(10,2) NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('bezahlt','offen','überfällig')), + paid_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_transactions_unit ON transactions(unit_id); + +CREATE TABLE IF NOT EXISTS tickets ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL REFERENCES units(id), + tenant TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + priority TEXT NOT NULL CHECK (priority IN ('niedrig','mittel','hoch')), + status TEXT NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','in Bearbeitung','erledigt')), + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tickets_unit ON tickets(unit_id); + +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + unit_id TEXT REFERENCES units(id), -- NULL = Hausdokument für alle + title TEXT NOT NULL, + category TEXT NOT NULL, + size TEXT NOT NULL, + date TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + unit_id TEXT NOT NULL REFERENCES units(id), + tenant TEXT NOT NULL, + subject TEXT NOT NULL, + unread_by_admin BOOLEAN NOT NULL DEFAULT FALSE, + unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS idx_threads_unit ON threads(unit_id); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + from_role TEXT NOT NULL CHECK (from_role IN ('tenant','admin')), + author TEXT NOT NULL, + text TEXT NOT NULL, + at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); + +CREATE TABLE IF NOT EXISTS announcements ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + text TEXT NOT NULL, + date TEXT NOT NULL, + tag TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + benutzername TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + passwort_hash TEXT NOT NULL, + rolle TEXT NOT NULL CHECK (rolle IN ('verwaltung','mieter')), + email TEXT NOT NULL, + einheit_id TEXT REFERENCES units(id), + aktiv BOOLEAN NOT NULL DEFAULT TRUE, + erstellt_am TEXT NOT NULL, + letzter_login TEXT +); + +CREATE TABLE IF NOT EXISTS registration_codes ( + code TEXT PRIMARY KEY, + einheit_id TEXT REFERENCES units(id), + erstellt_am TEXT NOT NULL, + verwendet_am TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +` + +async function init() { + await pool.query(SCHEMA) +} + +/** Aufräumen abgelaufener Sessions (gelegentlich aufgerufen). */ +async function sessionsAufraeumen() { + await pool.query('DELETE FROM sessions WHERE expires < now()') +} + +module.exports = { pool, init, sessionsAufraeumen } diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..002bd9e --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,474 @@ +/** Hinterstein Mieterportal – Node-API (Express + PostgreSQL). + * + * Authentifizierung: Session-Cookie mp_session (HttpOnly, 8 h), Passwörter als + * SHA-256-Hash (wie bisher im Browser berechnet, Vergleich serverseitig). + * Rollenrechte: Mieter sehen nur Daten ihrer Wohnung, Verwaltung alles. + * Benachrichtigungen (Ticket/Nachricht) werden serverseitig per Mail verschickt. + */ + +const express = require('express') +const crypto = require('crypto') +const { pool, init, sessionsAufraeumen } = require('./db') +const { seed } = require('./seed') +const mail = require('./mail') + +const PORT = Number(process.env.PORT || 3000) +const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 +const COOKIE_NAME = 'mp_session' + +const app = express() +app.use(express.json({ limit: '256kb' })) +app.set('trust proxy', true) + +// ── Cookie-Helfer (bewusst ohne Dependency) ────────────────── +function cookieLesen(req, name) { + const raw = req.headers.cookie || '' + for (const teil of raw.split(';')) { + const i = teil.indexOf('=') + if (i > -1 && teil.slice(0, i).trim() === name) return decodeURIComponent(teil.slice(i + 1).trim()) + } + return null +} + +function cookieSetzen(res, token, maxAgeMs, secure) { + res.append( + 'Set-Cookie', + `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; ` + + `Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? '; Secure' : ''}`, + ) +} + +// ── Auth-Middleware ────────────────────────────────────────── +async function auth(req, res, next) { + try { + const token = cookieLesen(req, COOKIE_NAME) + if (token) { + const { rows } = await pool.query( + `SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = $1 AND s.expires > now() AND u.aktiv`, + [token], + ) + if (rows.length) { + req.user = rows[0] + req.sessionToken = token + return next() + } + } + res.status(401).json({ fehler: 'Nicht angemeldet.' }) + } catch (e) { + next(e) + } +} + +function adminOnly(req, res, next) { + if (req.user.rolle !== 'verwaltung') return res.status(403).json({ fehler: 'Nur für die Verwaltung.' }) + next() +} + +const oeffentlicherBenutzer = (b) => ({ + id: b.id, + benutzername: b.benutzername, + name: b.name, + rolle: b.rolle, + email: b.email, + einheitId: b.einheit_id, + aktiv: b.aktiv, + erstelltAm: b.erstellt_am, + letzterLogin: b.letzter_login ?? undefined, +}) + +async function sessionAnlegen(res, userId, req) { + const token = crypto.randomBytes(32).toString('hex') + await pool.query( + 'INSERT INTO sessions (token, user_id, expires) VALUES ($1, $2, now() + $3 * interval \'1 millisecond\')', + [token, userId, SESSION_DAUER_MS], + ) + cookieSetzen(res, token, SESSION_DAUER_MS, req.secure) +} + +// ── Mapping-Helfer (snake_case → camelCase) ────────────────── +const mapUnit = (u) => ({ + id: u.id, building: u.building, number: u.number, floor: u.floor, + size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent), + tenant: u.tenant, status: u.status, +}) +const mapTransaction = (t) => ({ + id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year, + amount: Number(t.amount), type: t.type, status: t.status, paidAt: t.paid_at ?? undefined, +}) +const mapTicket = (t) => ({ + id: t.id, unitId: t.unit_id, tenant: t.tenant, title: t.title, + description: t.description, category: t.category, priority: t.priority, + status: t.status, createdAt: t.created_at, +}) +const mapDocument = (d) => ({ + id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date, +}) +const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at }) +const mapThread = (t) => ({ + id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject, + unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant, +}) +const mapCode = (c) => ({ code: c.code, einheitId: c.einheit_id, erstelltAm: c.erstellt_am, verwendetAm: c.verwendet_am ?? undefined }) +const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: a.date, tag: a.tag }) + +const deutschesDatum = () => new Date().toLocaleDateString('de-DE') +const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') + +// ── Health ─────────────────────────────────────────────────── +app.get('/api/healthz', async (_req, res) => { + try { + await pool.query('SELECT 1') + res.json({ ok: true, dienst: 'mp-api', datenbank: true }) + } catch { + res.status(503).json({ ok: false, datenbank: false }) + } +}) + +// ── Auth ───────────────────────────────────────────────────── +app.post('/api/auth/login', async (req, res, next) => { + try { + const { benutzername, passwortHash } = req.body || {} + const { rows } = await pool.query( + 'SELECT * FROM users WHERE lower(benutzername) = lower($1)', + [String(benutzername || '').trim()], + ) + const b = rows[0] + if (!b || String(passwortHash || '') !== b.passwort_hash) + return res.status(401).json({ fehler: 'Benutzername oder Passwort falsch.' }) + if (!b.aktiv) + return res.status(403).json({ fehler: 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.' }) + await pool.query('UPDATE users SET letzter_login = $1 WHERE id = $2', [deutschesDatumZeit(), b.id]) + await sessionAnlegen(res, b.id, req) + res.json({ benutzer: oeffentlicherBenutzer(b) }) + } catch (e) { next(e) } +}) + +app.post('/api/auth/logout', async (req, res) => { + const token = cookieLesen(req, COOKIE_NAME) + if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [token]) + cookieSetzen(res, '', 0, req.secure) + res.json({ ok: true }) +}) + +app.get('/api/auth/me', auth, (req, res) => { + res.json({ benutzer: oeffentlicherBenutzer(req.user) }) +}) + +app.post('/api/auth/register', async (req, res, next) => { + try { + const { code, benutzername, name, email, passwortHash } = req.body || {} + const codeNorm = String(code || '').trim().toUpperCase() + const benutzernameNorm = String(benutzername || '').trim() + // Erst Benutzername prüfen, dann Code atomar verbrauchen (sonst wäre der + // Code bei einem vergebenen Benutzernamen trotzdem verbraucht) + const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm]) + if (vorhanden.rows.length) + return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' }) + const { rows: codeRows } = await pool.query( + `UPDATE registration_codes SET verwendet_am = $1 + WHERE code = $2 AND verwendet_am IS NULL + RETURNING einheit_id`, + [deutschesDatum(), codeNorm], + ) + if (!codeRows.length) + return res.status(400).json({ fehler: 'Der Registrierungscode ist ungültig oder bereits verwendet.' }) + const id = `b-${Date.now()}` + const { rows } = await pool.query( + `INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am) + VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7) RETURNING *`, + [id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''), + String(email || '').trim(), codeRows[0].einheit_id, deutschesDatum()], + ) + await sessionAnlegen(res, id, req) + res.json({ benutzer: oeffentlicherBenutzer(rows[0]) }) + } catch (e) { next(e) } +}) + +// ── Bootstrap (alle Stammdaten in einem Rutsch) ────────────── +app.get('/api/bootstrap', auth, async (req, res, next) => { + try { + const istAdmin = req.user.rolle === 'verwaltung' + const einheit = req.user.einheit_id + const [units, transactions, documents, tickets, threads, messages, announcements, users, codes] = + await Promise.all([ + pool.query('SELECT * FROM units ORDER BY id'), + istAdmin + ? pool.query('SELECT * FROM transactions ') + : pool.query('SELECT * FROM transactions WHERE unit_id = $1 ', [einheit]), + istAdmin + ? pool.query('SELECT * FROM documents ORDER BY date DESC') + : pool.query('SELECT * FROM documents WHERE unit_id = $1 OR unit_id IS NULL ORDER BY date DESC', [einheit]), + istAdmin + ? pool.query('SELECT * FROM tickets ORDER BY created_at DESC') + : pool.query('SELECT * FROM tickets WHERE unit_id = $1 ORDER BY created_at DESC', [einheit]), + istAdmin + ? pool.query('SELECT * FROM threads ORDER BY id DESC') + : pool.query('SELECT * FROM threads WHERE unit_id = $1 ORDER BY id DESC', [einheit]), + istAdmin + ? pool.query('SELECT * FROM messages') + : pool.query('SELECT m.* FROM messages m JOIN threads t ON t.id = m.thread_id WHERE t.unit_id = $1', [einheit]), + pool.query('SELECT * FROM announcements ORDER BY date DESC'), + istAdmin + ? pool.query('SELECT * FROM users ORDER BY name') + : { rows: [] }, + istAdmin + ? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC') + : { rows: [] }, + ]) + // Threads mit ihren Nachrichten zusammenführen + const nachrichtenProThread = {} + for (const m of messages.rows) { + ;(nachrichtenProThread[m.thread_id] ??= []).push(mapMessage(m)) + } + const threadListe = threads.rows.map((t) => ({ + ...mapThread(t), + messages: nachrichtenProThread[t.id] ?? [], + })) + res.json({ + units: units.rows.map(mapUnit), + transactions: transactions.rows.map(mapTransaction), + documents: documents.rows.map(mapDocument), + tickets: tickets.rows.map(mapTicket), + threads: threadListe, + announcements: announcements.rows.map(mapAnnouncement), + benutzer: users.rows.map(oeffentlicherBenutzer), + codes: codes.rows.map(mapCode), + }) + } catch (e) { next(e) } +}) + +// ── Tickets ────────────────────────────────────────────────── +app.post('/api/tickets', auth, async (req, res, next) => { + try { + const { title, description, category, priority } = req.body || {} + if (!String(title || '').trim()) return res.status(400).json({ fehler: 'Titel fehlt.' }) + const unitId = req.user.rolle === 'verwaltung' + ? String((req.body || {}).unitId || 'hs-1') + : req.user.einheit_id + const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) + if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + const id = `t-${Date.now()}` + const mieterName = req.user.rolle === 'verwaltung' ? 'Hinterstein Immobilien (intern)' : req.user.name + const { rows } = await pool.query( + `INSERT INTO tickets (id, unit_id, tenant, title, description, category, priority, status, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,'offen',$8) RETURNING *`, + [id, unitId, mieterName, String(title).trim(), String(description || '–').trim(), + String(category || 'Sonstiges'), ['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel', + deutschesDatum()], + ) + const label = mail.einheitLabel(mapUnit(unitRows[0])) + mail.benachrichtigeVerwaltung( + `Neue Schadensmeldung: ${String(title).trim()} (${label})`, + `Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` + + `Wohnung: ${label}\nMeldung von: ${mieterName}\nKategorie: ${String(category || 'Sonstiges')}\n\n` + + `${String(title).trim()}\n${String(description || '–').trim()}`, + ) + res.json({ ticket: mapTicket(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => { + try { + const status = String((req.body || {}).status || '') + if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status)) + return res.status(400).json({ fehler: 'Ungültiger Status.' }) + const { rows } = await pool.query( + 'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id], + ) + if (!rows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' }) + res.json({ ticket: mapTicket(rows[0]) }) + } catch (e) { next(e) } +}) + +// ── Nachrichten (Threads) ──────────────────────────────────── +app.post('/api/threads', auth, async (req, res, next) => { + try { + const { subject, text, unitId: gewuenschteEinheit } = req.body || {} + if (!String(subject || '').trim() || !String(text || '').trim()) + return res.status(400).json({ fehler: 'Betreff und Nachricht fehlen.' }) + const unitId = req.user.rolle === 'verwaltung' + ? String(gewuenschteEinheit || 'hs-1') + : req.user.einheit_id + const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) + if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + const id = `m-${Date.now()}` + const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant' + const autor = req.user.name + await pool.query( + `INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant) + VALUES ($1,$2,$3,$4,$5,$6)`, + [id, unitId, req.user.rolle === 'verwaltung' ? autor : (unitRows[0].tenant ?? autor), + String(subject).trim(), vonRolle === 'tenant', vonRolle === 'admin'], + ) + await pool.query( + `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`, + [`${id}-1`, id, vonRolle, autor, String(text).trim(), deutschesDatumZeit()], + ) + const betreff = vonRolle === 'tenant' + ? `Neue Nachricht im Mieterportal: ${String(subject).trim()}` + : `Nachricht der Verwaltung: ${String(subject).trim()}` + const inhalt = `Neue Unterhaltung von ${autor}:\n\n„${String(text).trim()}“` + if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt) + else mail.benachrichtigeMieter(unitId, betreff, inhalt) + const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [id]) + const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [id]) + res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } }) + } catch (e) { next(e) } +}) + +app.post('/api/threads/:id/messages', auth, async (req, res, next) => { + try { + const text = String((req.body || {}).text || '').trim() + if (!text) return res.status(400).json({ fehler: 'Nachricht fehlt.' }) + const { rows: threadRows } = await pool.query('SELECT * FROM threads WHERE id = $1', [req.params.id]) + const th = threadRows[0] + if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' }) + if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id) + return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' }) + const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant' + const autor = req.user.name + const msgId = `${th.id}-${Date.now()}` + await pool.query( + `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`, + [msgId, th.id, vonRolle, autor, text, deutschesDatumZeit()], + ) + await pool.query( + `UPDATE threads SET unread_by_admin = $1, unread_by_tenant = $2 WHERE id = $3`, + [vonRolle === 'tenant', vonRolle === 'admin', th.id], + ) + const betreff = vonRolle === 'tenant' + ? `Neue Antwort im Mieterportal: ${th.subject}` + : `Antwort der Verwaltung: ${th.subject}` + const inhalt = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${text}“` + if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt) + else mail.benachrichtigeMieter(th.unit_id, betreff, inhalt) + const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [th.id]) + const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [th.id]) + res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } }) + } catch (e) { next(e) } +}) + +app.post('/api/threads/:id/read', auth, async (req, res, next) => { + try { + const { rows: threadRows } = await pool.query('SELECT unit_id FROM threads WHERE id = $1', [req.params.id]) + const th = threadRows[0] + if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' }) + if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id) + return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' }) + if (req.user.rolle === 'verwaltung') + await pool.query('UPDATE threads SET unread_by_admin = FALSE WHERE id = $1', [req.params.id]) + else + await pool.query('UPDATE threads SET unread_by_tenant = FALSE WHERE id = $1', [req.params.id]) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + +// ── Benutzerverwaltung (nur Verwaltung) ────────────────────── +app.get('/api/users', auth, adminOnly, async (_req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM users ORDER BY name') + res.json({ benutzer: rows.map(oeffentlicherBenutzer) }) + } catch (e) { next(e) } +}) + +app.post('/api/users', auth, adminOnly, async (req, res, next) => { + try { + const { benutzername, name, rolle, email, passwortHash, einheitId } = req.body || {} + if (!['verwaltung', 'mieter'].includes(rolle)) return res.status(400).json({ fehler: 'Ungültige Rolle.' }) + const benutzernameNorm = String(benutzername || '').trim() + const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm]) + if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' }) + const id = `b-${Date.now()}` + const { rows } = await pool.query( + `INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,TRUE,$8) RETURNING *`, + [id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''), rolle, + String(email || '').trim(), rolle === 'mieter' ? (einheitId || null) : null, deutschesDatum()], + ) + res.json({ benutzer: oeffentlicherBenutzer(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => { + try { + const b = req.body || {} + const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]) + if (!rows.length) return res.status(404).json({ fehler: 'Benutzer nicht gefunden.' }) + if (b.passwortHash !== undefined) + await pool.query('UPDATE users SET passwort_hash = $1 WHERE id = $2', [String(b.passwortHash), req.params.id]) + if (b.aktiv !== undefined) + await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id]) + if (b.einheitId !== undefined) + await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id]) + const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]) + res.json({ benutzer: oeffentlicherBenutzer(neu[0]) }) + } catch (e) { next(e) } +}) + +// ── Registrierungscodes (nur Verwaltung) ───────────────────── +app.get('/api/codes', auth, adminOnly, async (_req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC') + res.json({ codes: rows.map(mapCode) }) + } catch (e) { next(e) } +}) + +app.post('/api/codes', auth, adminOnly, async (req, res, next) => { + try { + const anzahl = Math.min(Math.max(parseInt((req.body || {}).anzahl, 10) || 1, 1), 50) + const einheitId = (req.body || {}).einheitId || null + if (einheitId) { + const u = await pool.query('SELECT 1 FROM units WHERE id = $1', [einheitId]) + if (!u.rows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) + } + const teil = () => + Array.from(crypto.randomBytes(2)).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase() + const neu = [] + for (let i = 0; i < anzahl; i++) { + const code = `MP-${teil()}-${teil()}` + await pool.query( + 'INSERT INTO registration_codes (code, einheit_id, erstellt_am) VALUES ($1,$2,$3)', + [code, einheitId, deutschesDatum()], + ) + neu.push({ code, einheitId, erstelltAm: deutschesDatum() }) + } + res.json({ codes: neu }) + } catch (e) { next(e) } +}) + +// ── Fehlerbehandlung ───────────────────────────────────────── +app.use((err, _req, res, _next) => { + console.error('[api]', err) + res.status(500).json({ fehler: 'Interner Serverfehler.' }) +}) + +// ── Start ──────────────────────────────────────────────────── +async function warteAufDatenbank() { + let letzterFehler = null + for (let versuch = 1; versuch <= 30; versuch++) { + try { + await init() + return + } catch (e) { + letzterFehler = e + console.log(`[api] Datenbank nicht bereit (Versuch ${versuch}/30) …`) + await new Promise((r) => setTimeout(r, 2000)) + } + } + throw letzterFehler +} + +async function start() { + // Warten, bis die Datenbank erreichbar ist (Container-Start-Reihenfolge) + await warteAufDatenbank() + const geseedet = await seed() + if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)') + setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref() + app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`)) +} + +start().catch((e) => { + console.error('[api] Start fehlgeschlagen:', e) + process.exit(1) +}) diff --git a/server/src/mail.js b/server/src/mail.js new file mode 100644 index 0000000..7e54208 --- /dev/null +++ b/server/src/mail.js @@ -0,0 +1,49 @@ +/** Serverseitiger E-Mail-Versand über die Portal-Mail-API (mp-mail-api). + * + * Fire-and-forget: Ein Fehler (Mail-API down, Relay-Fehler) beeinträchtigt + * die auslösende Portal-Aktion nie. Die Mail-API selbst puffert im Fehlerfall + * in ihrer Outbox und versucht es erneut. + */ + +const MAIL_API_URL = process.env.MAIL_API_URL || '' // z. B. http://mp-mail-api:8080 +const MAIL_API_TOKEN = process.env.MAIL_API_TOKEN || '' + +const FUSSZEILE = + '\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien' + +async function sendeMail(an, betreff, text) { + if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return + try { + const res = await fetch(`${MAIL_API_URL}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN }, + body: JSON.stringify({ to: an, subject: betreff, text: `${text}${FUSSZEILE}` }), + }) + if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`) + } catch (e) { + console.warn(`[mail] Versand an ${an} fehlgeschlagen:`, e.message) + } +} + +/** Alle aktiven Verwaltungsnutzer benachrichtigen. */ +async function benachrichtigeVerwaltung(betreff, text) { + const { rows } = await require('./db').pool.query( + `SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`, + ) + for (const r of rows) sendeMail(r.email, betreff, text) +} + +/** Mieter einer bestimmten Wohnung benachrichtigen. */ +async function benachrichtigeMieter(einheitId, betreff, text) { + const { rows } = await require('./db').pool.query( + `SELECT email FROM users WHERE rolle = 'mieter' AND aktiv AND einheit_id = $1 AND email <> ''`, + [einheitId], + ) + for (const r of rows) sendeMail(r.email, betreff, text) +} + +function einheitLabel(u) { + return u ? `${u.building}, Whg. ${u.number}` : '' +} + +module.exports = { sendeMail, benachrichtigeVerwaltung, benachrichtigeMieter, einheitLabel } diff --git a/server/src/seed-data.js b/server/src/seed-data.js new file mode 100644 index 0000000..5fc874e --- /dev/null +++ b/server/src/seed-data.js @@ -0,0 +1,144 @@ +/** Handgeschriebene Demo-Daten (vormals src/data/seed.ts). + * + * units + transactions kommen maschinell aus seed/generated.json + * (beispieldaten.py). Die Verwaltungs-E-Mail ist bewusst ein echtes, + * zustellbares Postfach (axel@hocks.eu), damit Benachrichtigungen ankommen; + * im Portal änderbar (Benutzerverwaltung). + */ + +const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815") + +const users = [ + { + id: 'b-joseph', + benutzername: 'j.hinterstein', + name: 'Joseph Hinterstein', + passwort_hash: DEMO_HASH, + rolle: 'verwaltung', + email: 'axel@hocks.eu', + einheit_id: null, + aktiv: true, + erstellt_am: '01.03.2024', + }, + { + id: 'b-anna', + benutzername: 'a.berger', + name: 'Anna Berger', + passwort_hash: DEMO_HASH, + rolle: 'mieter', + email: 'anna.berger@example.de', + einheit_id: 'hs-7', + aktiv: true, + erstellt_am: '01.03.2024', + letzter_login: '02.10.2026, 09:12', + }, + { + id: 'b-jonas', + benutzername: 'm.wolf', + name: 'Jonas Klein', + passwort_hash: DEMO_HASH, + rolle: 'mieter', + email: 'jonas.klein@example.de', + einheit_id: 'rw-4', + aktiv: true, + erstellt_am: '15.08.2025', + }, +] + +const registrationCodes = [ + { code: 'MP-2026-0815', einheit_id: 'ap-2', erstellt_am: '02.10.2026', verwendet_am: null }, +] + +const tickets = [ + { + id: 't-1', unit_id: 'hs-7', tenant: 'Anna Berger', + title: 'Wasserhahn im Bad tropft', + description: 'Der Wasserhahn am Waschbecken tropft seit einigen Tagen permanent, auch bei ganz zugedrehtem Ventil.', + category: 'Sanitär', priority: 'mittel', status: 'in Bearbeitung', created_at: '28.09.2026', + }, + { + id: 't-2', unit_id: 'hs-7', tenant: 'Anna Berger', + title: 'Briefkastenschlüssel defekt', + description: 'Der Schlüssel für den Briefkasten lässt sich nicht mehr drehen, vermutlich Schloss verklebt.', + category: 'Schlüssel', priority: 'niedrig', status: 'offen', created_at: '30.09.2026', + }, + { + id: 't-3', unit_id: 'rw-4', tenant: 'Jonas Klein', + title: 'Heizkörper wird nicht warm', + description: 'Der Heizkörper im Wohnzimmer bleibt kalt, obwohl das Thermostat voll aufgedreht ist.', + category: 'Heizung', priority: 'hoch', status: 'offen', created_at: '01.10.2026', + }, + { + id: 't-4', unit_id: 'ap-2', tenant: 'Anja Roth', + title: 'Rollladen im Schlafzimmer klemmt', + description: 'Der elektrische Rollladen fährt nur noch zur Hälfte hoch.', + category: 'Fenster / Rollladen', priority: 'mittel', status: 'in Bearbeitung', created_at: '25.09.2026', + }, + { + id: 't-5', unit_id: 'hs-14', tenant: 'Markus Lange', + title: 'Flurbeleuchtung im Treppenhaus defekt', + description: 'Im 3. OG ist die Bewegungsmelder-Leuchte ausgefallen.', + category: 'Elektrik', priority: 'hoch', status: 'erledigt', created_at: '18.09.2026', + }, + { + id: 't-6', unit_id: 'rw-9', tenant: 'Sandra Weiß', + title: 'Schimmel im Bad – Lüftungsproblem?', + description: 'In der Fensterfalz des Bades sammelt sich Kondenswasser, bitte um Prüfung der Lüftung.', + category: 'Sanitär', priority: 'hoch', status: 'in Bearbeitung', created_at: '22.09.2026', + }, + { + id: 't-7', unit_id: 'ap-11', tenant: 'Thomas Neumann', + title: 'Markise auf dem Balkon lose', + description: 'Die Befestigung der Markise wackelt bei Wind deutlich.', + category: 'Balkon / Außenanlage', priority: 'hoch', status: 'offen', created_at: '29.09.2026', + }, + { + id: 't-8', unit_id: 'hs-3', tenant: 'Petra Hoffmann', + title: 'Spülmaschinen-Anschluss undicht', + description: 'Unter der Spüle steht Wasser, vermutlich kommt es vom Geräteanschluss.', + category: 'Sanitär', priority: 'mittel', status: 'erledigt', created_at: '10.09.2026', + }, +] + +// Mieter-Namen für tickets der fremden Wohnungen stammen aus generated.json; +// seed.js ersetzt leere/Platzhalter-Namen beim Einfügen aus der units-Tabelle. + +const documents = [ + { id: 'd-1', unit_id: 'hs-7', title: 'Mietvertrag Anna Berger – Hauptstraße 12, Whg. 7', category: 'Mietvertrag', size: '1,8 MB', date: '01.03.2024' }, + { id: 'd-2', unit_id: 'hs-7', title: 'Nebenkostenabrechnung 2025', category: 'Nebenkostenabrechnung', size: '640 KB', date: '31.05.2026' }, + { id: 'd-3', unit_id: 'hs-7', title: 'Heizkostenabrechnung 2025', category: 'Nebenkostenabrechnung', size: '410 KB', date: '31.05.2026' }, + { id: 'd-4', unit_id: null, title: 'Hausordnung – gültige Fassung 2026', category: 'Hausordnung', size: '220 KB', date: '01.01.2026' }, + { id: 'd-5', unit_id: null, title: 'Protokoll Hausversammlung 14.06.2026', category: 'Protokoll', size: '350 KB', date: '14.06.2026' }, + { id: 'd-6', unit_id: null, title: 'Mülltonnen-Belegungsplan & Abfuhrtermine 2026', category: 'Sonstiges', size: '180 KB', date: '02.01.2026' }, + { id: 'd-7', unit_id: 'rw-4', title: 'Mietvertrag – Ringweg 3, Whg. 4', category: 'Mietvertrag', size: '1,6 MB', date: '15.08.2025' }, + { id: 'd-8', unit_id: 'ap-2', title: 'Mietvertrag – Am Park 5, Whg. 2', category: 'Mietvertrag', size: '1,7 MB', date: '01.06.2024' }, +] + +const threads = [ + { + id: 'm-1', unit_id: 'hs-7', tenant: 'Anna Berger', + subject: 'Mietkaution – Rückzahlungsnachweis', + unread_by_admin: false, unread_by_tenant: true, + messages: [ + { id: 'm-1-a', from_role: 'tenant', author: 'Anna Berger', text: 'Guten Tag, könnten Sie mir bitte den aktuellen Stand meiner Mietkaution mit Nachweis der Verzinsung zusenden?', at: '27.09.2026, 10:12' }, + { id: 'm-1-b', from_role: 'admin', author: 'Joseph Hinterstein', text: 'Guten Tag Frau Berger, die Kaution wurde mit 1.500,00 € auf dem Treuhandkonto verbucht. Die Verzinsung für 2025 beträgt 18,40 € – den Nachweis finden Sie ab sofort auch unter Dokumente.', at: '29.09.2026, 09:03' }, + ], + }, + { + id: 'm-2', unit_id: 'rw-9', tenant: 'Sandra Weiß', + subject: 'Terminabstimmung Wohnungsbegehung (Schimmel)', + unread_by_admin: true, unread_by_tenant: false, + messages: [ + { id: 'm-2-a', from_role: 'tenant', author: 'Sandra Weiß', text: 'Aufgrund der Schadensmeldung schlage ich einen Begehungstermin nächste Woche vor. Passt Mittwoch oder Freitag Nachmittag?', at: '01.10.2026, 08:45' }, + ], + }, +] + +const announcements = [ + { id: 'a-1', title: 'Wasserabschaltung Hauptstraße 12', text: 'Am Donnerstag, 08.10.2026, 09:00–13:00 Uhr findet eine Druckerhöhungs-Pumpen-Wartung statt. In dieser Zeit steht kein Wasser zur Verfügung.', date: '02.10.2026', tag: 'Technik' }, + { id: 'a-2', title: 'Herbst-Hausbegehung mit Hausmeister', text: 'Am Samstag, 17.10.2026, 10:00 Uhr geht es gemeinsam durch alle Häuser. Anschließend gibt es Kaffee im Hof von Am Park 5.', date: '28.09.2026', tag: 'Termin' }, + { id: 'a-3', title: 'Tiefgarage Ringweg 3: Reinigung', text: 'Die Tiefgarage wird am 21.10.2026 gereinigt. Bitte Fahrzeuge bis 07:30 Uhr entfernen, sonst erfolgt ein kostenpflichtiges Versetzen.', date: '20.09.2026', tag: 'Hinweis' }, + { id: 'a-4', title: 'Nebenkostenabrechnungen 2025 versandt', text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.', date: '31.05.2026', tag: 'Abrechnung' }, +] + +module.exports = { users, registrationCodes, tickets, documents, threads, announcements } diff --git a/server/src/seed.js b/server/src/seed.js new file mode 100644 index 0000000..eb22338 --- /dev/null +++ b/server/src/seed.js @@ -0,0 +1,100 @@ +/** Idempotenter Seed: füllt die Datenbank beim ersten Start mit den + * Demo-Daten (50 Wohnungen, 552 Buchungen, Tickets, Dokumente, Threads, + * Ankündigungen, Demo-Benutzer, Registrierungscode). Läuft nur, wenn die + * users-Tabelle leer ist – bestehende Daten werden nie überschrieben. + */ + +const { pool } = require('./db') +const { users, registrationCodes, tickets, documents, threads, announcements } = require('./seed-data') +const generated = require('../seed/generated.json') + +async function seed() { + const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users') + if (rows[0].n > 0) return false + + const client = await pool.connect() + try { + await client.query('BEGIN') + + for (const u of generated.units) { + await client.query( + `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.status], + ) + } + + for (const t of generated.transactions) { + await client.query( + `INSERT INTO transactions (id, unit_id, tenant, month, year, amount, type, status, paid_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [t.id, t.unitId, t.tenant, t.month, t.year, t.amount, t.type, t.status, t.paidAt ?? null], + ) + } + + // Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen + const { rows: unitRows } = await client.query('SELECT id, tenant FROM units') + const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant])) + + for (const t of tickets) { + await client.query( + `INSERT INTO tickets (id, unit_id, tenant, title, description, category, priority, status, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [t.id, t.unit_id, mieterVon[t.unit_id] ?? t.tenant, t.title, t.description, t.category, t.priority, t.status, t.created_at], + ) + } + + for (const d of documents) { + await client.query( + `INSERT INTO documents (id, unit_id, title, category, size, date) VALUES ($1,$2,$3,$4,$5,$6)`, + [d.id, d.unit_id, d.title, d.category, d.size, d.date], + ) + } + + for (const th of threads) { + await client.query( + `INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant) + VALUES ($1,$2,$3,$4,$5,$6)`, + [th.id, th.unit_id, mieterVon[th.unit_id] ?? th.tenant, th.subject, th.unread_by_admin, th.unread_by_tenant], + ) + for (const m of th.messages) { + await client.query( + `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`, + [m.id, th.id, m.from_role, m.author, m.text, m.at], + ) + } + } + + for (const a of announcements) { + await client.query( + `INSERT INTO announcements (id, title, text, date, tag) VALUES ($1,$2,$3,$4,$5)`, + [a.id, a.title, a.text, a.date, a.tag], + ) + } + + for (const b of users) { + await client.query( + `INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am, letzter_login) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [b.id, b.benutzername, b.name, b.passwort_hash, b.rolle, b.email, b.einheit_id, b.aktiv, b.erstellt_am, b.letzter_login ?? null], + ) + } + + for (const c of registrationCodes) { + await client.query( + `INSERT INTO registration_codes (code, einheit_id, erstellt_am, verwendet_am) VALUES ($1,$2,$3,$4)`, + [c.code, c.einheit_id, c.erstellt_am, c.verwendet_am], + ) + } + + await client.query('COMMIT') + return true + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} + +module.exports = { seed } diff --git a/src/App.tsx b/src/App.tsx index 531f0a4..3af44a4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,14 +9,27 @@ import Documents from './pages/Documents' import Messages from './pages/Messages' import BenutzerVerwaltung from './pages/BenutzerVerwaltung' +function Ladebildschirm() { + return ( +
+
+
Hinterstein Immobilien
+
Mieterportal wird geladen …
+
+
+ ) +} + function RequireAuth({ children }: { children: React.ReactElement }) { - const { me } = useStore() + const { me, bereit } = useStore() + if (!bereit) return if (!me) return return children } function LoginGate() { - const { me } = useStore() + const { me, bereit } = useStore() + if (!bereit) return if (me) return return } diff --git a/src/data/seed.ts b/src/data/seed.ts deleted file mode 100644 index 3b3fed0..0000000 --- a/src/data/seed.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { - Unit, - Transaction, - Ticket, - DocumentItem, - Thread, - Announcement, -} from '../types' -import generated from './generated.json' - -// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7 -export const DEMO_TENANT = 'Anna Berger' -export const DEMO_UNIT_ID = 'hs-7' - -// Maschinell erzeugte Bestandsdaten (beispieldaten.py -> generated.json) -export const units = generated.units as Unit[] -export const transactions = generated.transactions as Transaction[] - -export const tickets: Ticket[] = [ - { - id: 't-1', - unitId: DEMO_UNIT_ID, - tenant: DEMO_TENANT, - title: 'Wasserhahn im Bad tropft', - description: - 'Der Wasserhahn am Waschbecken tropft seit einigen Tagen permanent, auch bei ganz zugedrehtem Ventil.', - category: 'Sanitär', - priority: 'mittel', - status: 'in Bearbeitung', - createdAt: '28.09.2026', - }, - { - id: 't-2', - unitId: DEMO_UNIT_ID, - tenant: DEMO_TENANT, - title: 'Briefkastenschlüssel defekt', - description: 'Der Schlüssel für den Briefkasten lässt sich nicht mehr drehen, vermutlich Schloss verklebt.', - category: 'Schlüssel', - priority: 'niedrig', - status: 'offen', - createdAt: '30.09.2026', - }, - { - id: 't-3', - unitId: 'rw-4', - tenant: units.find((u) => u.id === 'rw-4')?.tenant ?? '', - title: 'Heizkörper wird nicht warm', - description: 'Der Heizkörper im Wohnzimmer bleibt kalt, obwohl das Thermostat voll aufgedreht ist.', - category: 'Heizung', - priority: 'hoch', - status: 'offen', - createdAt: '01.10.2026', - }, - { - id: 't-4', - unitId: 'ap-2', - tenant: units.find((u) => u.id === 'ap-2')?.tenant ?? '', - title: 'Rollladen im Schlafzimmer klemmt', - description: 'Der elektrische Rollladen fährt nur noch zur Hälfte hoch.', - category: 'Fenster / Rollladen', - priority: 'mittel', - status: 'in Bearbeitung', - createdAt: '25.09.2026', - }, - { - id: 't-5', - unitId: 'hs-14', - tenant: units.find((u) => u.id === 'hs-14')?.tenant ?? '', - title: 'Flurbeleuchtung im Treppenhaus defekt', - description: 'Im 3. OG ist die Bewegungsmelder-Leuchte ausgefallen.', - category: 'Elektrik', - priority: 'hoch', - status: 'erledigt', - createdAt: '18.09.2026', - }, - { - id: 't-6', - unitId: 'rw-9', - tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '', - title: 'Schimmel im Bad – Lüftungsproblem?', - description: 'In der Fensterfalz des Bades sammelt sich Kondenswasser, bitte um Prüfung der Lüftung.', - category: 'Sanitär', - priority: 'hoch', - status: 'in Bearbeitung', - createdAt: '22.09.2026', - }, - { - id: 't-7', - unitId: 'ap-11', - tenant: units.find((u) => u.id === 'ap-11')?.tenant ?? '', - title: 'Markise auf dem Balkon lose', - description: 'Die Befestigung der Markise wackelt bei Wind deutlich.', - category: 'Balkon / Außenanlage', - priority: 'hoch', - status: 'offen', - createdAt: '29.09.2026', - }, - { - id: 't-8', - unitId: 'hs-3', - tenant: units.find((u) => u.id === 'hs-3')?.tenant ?? '', - title: 'Spülmaschinen-Anschluss undicht', - description: 'Unter der Spüle steht Wasser, vermutlich kommt es vom Geräteanschluss.', - category: 'Sanitär', - priority: 'mittel', - status: 'erledigt', - createdAt: '10.09.2026', - }, -] - -export const documents: DocumentItem[] = [ - { - id: 'd-1', - unitId: DEMO_UNIT_ID, - title: 'Mietvertrag Anna Berger – Hauptstraße 12, Whg. 7', - category: 'Mietvertrag', - size: '1,8 MB', - date: '01.03.2024', - }, - { - id: 'd-2', - unitId: DEMO_UNIT_ID, - title: 'Nebenkostenabrechnung 2025', - category: 'Nebenkostenabrechnung', - size: '640 KB', - date: '31.05.2026', - }, - { - id: 'd-3', - unitId: DEMO_UNIT_ID, - title: 'Heizkostenabrechnung 2025', - category: 'Nebenkostenabrechnung', - size: '410 KB', - date: '31.05.2026', - }, - { - id: 'd-4', - unitId: null, - title: 'Hausordnung – gültige Fassung 2026', - category: 'Hausordnung', - size: '220 KB', - date: '01.01.2026', - }, - { - id: 'd-5', - unitId: null, - title: 'Protokoll Hausversammlung 14.06.2026', - category: 'Protokoll', - size: '350 KB', - date: '14.06.2026', - }, - { - id: 'd-6', - unitId: null, - title: 'Mülltonnen-Belegungsplan & Abfuhrtermine 2026', - category: 'Sonstiges', - size: '180 KB', - date: '02.01.2026', - }, - { - id: 'd-7', - unitId: 'rw-4', - title: 'Mietvertrag – Ringweg 3, Whg. 4', - category: 'Mietvertrag', - size: '1,6 MB', - date: '15.08.2025', - }, - { - id: 'd-8', - unitId: 'ap-2', - title: 'Mietvertrag – Am Park 5, Whg. 2', - category: 'Mietvertrag', - size: '1,7 MB', - date: '01.06.2024', - }, -] - -export const threads: Thread[] = [ - { - id: 'm-1', - unitId: DEMO_UNIT_ID, - tenant: DEMO_TENANT, - subject: 'Mietkaution – Rückzahlungsnachweis', - unreadByAdmin: false, - unreadByTenant: true, - messages: [ - { - id: 'm-1-a', - from: 'tenant', - author: DEMO_TENANT, - text: 'Guten Tag, könnten Sie mir bitte den aktuellen Stand meiner Mietkaution mit Nachweis der Verzinsung zusenden?', - at: '27.09.2026, 10:12', - }, - { - id: 'm-1-b', - from: 'admin', - author: 'Joseph Hinterstein', - text: 'Guten Tag Frau Berger, die Kaution wurde mit 1.500,00 € auf dem Treuhandkonto verbucht. Die Verzinsung für 2025 beträgt 18,40 € – den Nachweis finden Sie ab sofort auch unter Dokumente.', - at: '29.09.2026, 09:03', - }, - ], - }, - { - id: 'm-2', - unitId: 'rw-9', - tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '', - subject: 'Terminabstimmung Wohnungsbegehung (Schimmel)', - unreadByAdmin: true, - unreadByTenant: false, - messages: [ - { - id: 'm-2-a', - from: 'tenant', - author: units.find((u) => u.id === 'rw-9')?.tenant ?? '', - text: 'Aufgrund der Schadensmeldung schlage ich einen Begehungstermin nächste Woche vor. Passt Mittwoch oder Freitag Nachmittag?', - at: '01.10.2026, 08:45', - }, - ], - }, -] - -export const announcements: Announcement[] = [ - { - id: 'a-1', - title: 'Wasserabschaltung Hauptstraße 12', - text: 'Am Donnerstag, 08.10.2026, 09:00–13:00 Uhr findet eine Druckerhöhungs-Pumpen-Wartung statt. In dieser Zeit steht kein Wasser zur Verfügung.', - date: '02.10.2026', - tag: 'Technik', - }, - { - id: 'a-2', - title: 'Herbst-Hausbegehung mit Hausmeister', - text: 'Am Samstag, 17.10.2026, 10:00 Uhr geht es gemeinsam durch alle Häuser. Anschließend gibt es Kaffee im Hof von Am Park 5.', - date: '28.09.2026', - tag: 'Termin', - }, - { - id: 'a-3', - title: 'Tiefgarage Ringweg 3: Reinigung', - text: 'Die Tiefgarage wird am 21.10.2026 gereinigt. Bitte Fahrzeuge bis 07:30 Uhr entfernen, sonst erfolgt ein kostenpflichtiges Versetzen.', - date: '20.09.2026', - tag: 'Hinweis', - }, - { - id: 'a-4', - title: 'Nebenkostenabrechnungen 2025 versandt', - text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.', - date: '31.05.2026', - tag: 'Abrechnung', - }, -] diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..978da23 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,54 @@ +/** Dünner Fetch-Client für die Portal-API (gleicher Ursprung, Session-Cookie). */ + +export class ApiFehler extends Error { + status: number + constructor(status: number, nachricht: string) { + super(nachricht) + this.status = status + } +} + +interface Antwort { + json: T | null + status: number +} + +async function anfrage(pfad: string, init?: RequestInit, json?: unknown): Promise> { + let res: Response + try { + res = await fetch(pfad, { + credentials: 'same-origin', + ...init, + headers: { + ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(init?.headers ?? {}), + }, + ...(json !== undefined ? { body: JSON.stringify(json) } : {}), + }) + } catch { + throw new ApiFehler(0, 'Keine Verbindung zum Server.') + } + let daten: T | null = null + try { + daten = (await res.json()) as T + } catch { + /* leerer Body */ + } + return { json: daten, status: res.status } +} + +/** Führt eine API-Anfrage aus und liefert Daten oder eine deutschsprachige Fehlermeldung. */ +export async function api(pfad: string, init?: RequestInit, json?: unknown): Promise { + const { json: daten, status } = await anfrage(pfad, init, json) + if (status >= 400 || daten === null) { + const fehler = + (daten as { fehler?: string } | null)?.fehler ?? + (status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${status}).`) + throw new ApiFehler(status, fehler) + } + return daten +} + +export const apiGet = (pfad: string) => api(pfad) +export const apiPost = (pfad: string, body?: unknown) => api(pfad, { method: 'POST' }, body ?? {}) +export const apiPatch = (pfad: string, body?: unknown) => api(pfad, { method: 'PATCH' }, body ?? {}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 083c928..8b6da40 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,8 @@ -/** Authentifizierung + Session, Muster aus dem Schulportal. */ - -import type { Benutzer, BenutzerRolle } from '../types' - -export const SESSION_KEY = 'mp_benutzer_session' -export const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 // 8 h -export const BENUTZER_CHANGED_EVENT = 'mp-benutzer-changed' +/** Passwort-Hashing (wie bisher: SHA-256 im Browser, Vergleich serverseitig). + * + * Die Sitzung selbst liegt seit v1.3 serverseitig (HttpOnly-Cookie, + * Tabelle sessions) – es gibt nichts mehr im localStorage zu verwalten. + */ /** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */ export const DEMO_PASSWORT = '0815' @@ -15,42 +13,3 @@ export async function sha256(text: string): Promise { .map((b) => b.toString(16).padStart(2, '0')) .join('') } - -export interface Session { - benutzerId: string - rolle: BenutzerRolle - bis: number -} - -export function ladeSession(): Session | null { - try { - const raw = localStorage.getItem(SESSION_KEY) - if (!raw) return null - const s = JSON.parse(raw) as Session - if (s.bis < Date.now()) { - localStorage.removeItem(SESSION_KEY) - return null - } - return s - } catch { - return null - } -} - -export function speichereSession(benutzerId: string, rolle: BenutzerRolle) { - const s: Session = { benutzerId, rolle, bis: Date.now() + SESSION_DAUER_MS } - localStorage.setItem(SESSION_KEY, JSON.stringify(s)) - window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT)) -} - -export function beendeSession() { - localStorage.removeItem(SESSION_KEY) - window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT)) -} - -export function aktuellerBenutzer(alle: Benutzer[]): Benutzer | null { - const s = ladeSession() - if (!s) return null - const b = alle.find((x) => x.id === s.benutzerId && x.aktiv) - return b ?? null -} diff --git a/src/lib/mail.ts b/src/lib/mail.ts deleted file mode 100644 index 85b2025..0000000 --- a/src/lib/mail.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** E-Mail-Benachrichtigungen über die Portal-Mail-API. - * - * Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom - * Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080) - * weitergereicht und dabei serverseitig mit dem API-Token authentifiziert. - * Das Token liegt also weder im Repo noch im JavaScript. - * - * Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline, - * API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet - * nur in der Browser-Konsole. - */ - -import type { Benutzer } from '../types' - -const ENDPUNKT = '/api/mail/send' - -export async function sendeMail(an: string, betreff: string, text: string): Promise { - try { - const res = await fetch(ENDPUNKT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ to: an, subject: betreff, text }), - }) - return res.ok - } catch { - return false - } -} - -/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */ -export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) { - const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean) - for (const adresse of adressen) { - sendeMail(adresse, betreff, text).then((ok) => { - if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`) - }) - } -} - -const FUSSZEILE = - '\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien' - -export function nachrichtenText(koerper: string): string { - return `${koerper}${FUSSZEILE}` -} - -/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */ -export function verwaltungsAdressen(benutzer: Benutzer[]): string[] { - return benutzer - .filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email) - .map((b) => b.email) -} - -/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */ -export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] { - return benutzer - .filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email) - .map((b) => b.email) -} - -/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */ -export function einheitLabel(einheit: { building: string; number: string }): string { - return `${einheit.building}, Whg. ${einheit.number}` -} diff --git a/src/pages/BenutzerVerwaltung.tsx b/src/pages/BenutzerVerwaltung.tsx index 6ec2023..5133e29 100644 --- a/src/pages/BenutzerVerwaltung.tsx +++ b/src/pages/BenutzerVerwaltung.tsx @@ -215,7 +215,10 @@ export default function BenutzerVerwaltung() { ))} - + @@ -166,7 +141,7 @@ function NewTicketDialog() { } export default function Tickets() { - const { tickets, units, role, dispatch, meineEinheit } = useStore() + const { tickets, units, role, setTicketStatus, meineEinheit } = useStore() const [filter, setFilter] = useState<'alle' | TicketStatus>('alle') const visible = useMemo( @@ -247,9 +222,7 @@ export default function Tickets() { {role === 'admin' ? (