v1.3: Serverseitige Datenbank (PostgreSQL + Node-API) ersetzt localStorage

- server/: Express-API (Auth, Bootstrap, Tickets, Threads, Benutzerverwaltung, Codes)
  mit pg-Schema (11 Tabellen), idempotem Seed (50 Wohnungen, 552 Buchungen)
- mp-db (PostgreSQL 16, Volume mp_pgdata) + mp-api im Compose-Stack,
  nginx proxiet /api/ an mp-api; deploy.sh baut API-Image auf dem Server
- Frontend: Store laedt /api/bootstrap, Schreibzugriff async, kein localStorage
  mehr (ausser Theme), Ladebildschirm beim Session-Check, vite-Proxy fuer Dev
- Rollenrechte serverseitig: Mieter nur eigene Wohnung (getestet, 403-Verweis)
- Mail-Benachrichtigungen komplett serverseitig (server/src/mail.js)
- beispieldaten.py schreibt server/seed/generated.json; Verwaltungs-Mail = axel@hocks.eu
- E2E live verifiziert: Login, Scoping, Ticket + Zustellung (status=sent),
  Registrierung, Codes; Testdaten bereinigt; Doku nachgefuehrt
This commit is contained in:
2026-10-02 12:54:44 +02:00
parent 5503ce5fc5
commit 311153b3c3
26 changed files with 8873 additions and 894 deletions
+19 -8
View File
@@ -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.
+6 -5
View File
@@ -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")
+15 -8
View File
@@ -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"
+48 -2
View File
@@ -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:
+10
View File
@@ -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;
+89 -54
View File
@@ -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=<empfaenger>`).
- 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

+20
View File
@@ -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"]
+982
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+129
View File
@@ -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 }
+474
View File
@@ -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)
})
+49
View File
@@ -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 }
+144
View File
@@ -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 }
+100
View File
@@ -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 }
+15 -2
View File
@@ -9,14 +9,27 @@ import Documents from './pages/Documents'
import Messages from './pages/Messages'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
function Ladebildschirm() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#22333b]">
<div className="text-center text-slate-300">
<div className="text-lg font-semibold text-white">Hinterstein Immobilien</div>
<div className="mt-1 text-sm">Mieterportal wird geladen …</div>
</div>
</div>
)
}
function RequireAuth({ children }: { children: React.ReactElement }) {
const { me } = useStore()
const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (!me) return <Navigate to="/login" replace />
return children
}
function LoginGate() {
const { me } = useStore()
const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (me) return <Navigate to="/" replace />
return <Login />
}
-251
View File
@@ -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',
},
]
+54
View File
@@ -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<T> {
json: T | null
status: number
}
async function anfrage<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<Antwort<T>> {
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<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<T> {
const { json: daten, status } = await anfrage<T>(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 = <T>(pfad: string) => api<T>(pfad)
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
+5 -46
View File
@@ -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<string> {
.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
}
-64
View File
@@ -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<boolean> {
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}`
}
+4 -1
View File
@@ -215,7 +215,10 @@ export default function BenutzerVerwaltung() {
))}
</select>
<Button
onClick={() => generiereCodes(anzahl, codeEinheit || null)}
onClick={async () => {
const neu = await generiereCodes(anzahl, codeEinheit || null)
if (neu.length > 0) kopieren(neu[0].code)
}}
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
>
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
+13 -86
View File
@@ -13,71 +13,21 @@ import {
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { useStore } from '../store/store'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
mieterAdressenFuerEinheit,
nachrichtenText,
} from '../lib/mail'
/** Gegenstelle benachrichtigen: Mieter → Verwaltung, Verwaltung → Mieter der Wohnung. */
function benachrichtigeGegenstelle(opts: {
benutzer: ReturnType<typeof useStore>['benutzer']
vonRolle: 'tenant' | 'admin'
einheitId: string
betreff: string
text: string
}) {
const adressen =
opts.vonRolle === 'tenant'
? verwaltungsAdressen(opts.benutzer)
: mieterAdressenFuerEinheit(opts.benutzer, opts.einheitId)
sendeBenachrichtigung(adressen, opts.betreff, nachrichtenText(opts.text))
}
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
const { dispatch, role, me, meineEinheit, benutzer } = useStore()
const { newThread, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState('')
const [text, setText] = useState('')
const verwaltungsName = 'Joseph Hinterstein'
const submit = () => {
const submit = async () => {
if (!subject.trim() || !text.trim()) return
const id = `m-${Date.now()}`
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName
dispatch({
type: 'newThread',
thread: {
id,
unitId,
tenant: autor,
subject: subject.trim(),
unreadByAdmin: true,
unreadByTenant: false,
messages: [
{
id: `${id}-1`,
from: role,
author: autor,
text: text.trim(),
at: new Date().toLocaleString('de-DE'),
},
],
},
})
benachrichtigeGegenstelle({
benutzer,
vonRolle: role,
einheitId: unitId,
betreff:
role === 'tenant'
? `Neue Nachricht im Mieterportal: ${subject.trim()}`
: `Nachricht der Verwaltung: ${subject.trim()}`,
text: `Neue Unterhaltung von ${autor}:\n\n„${text.trim()}“`,
})
const id = await newThread(
subject.trim(),
text.trim(),
role === 'tenant' ? meineEinheit.id : 'hs-1',
)
if (!id) return
setSubject('')
setText('')
setOpen(false)
@@ -117,9 +67,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
}
export default function Messages() {
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore()
const meinName = me?.name ?? 'Mieter'
const verwaltungsName = 'Joseph Hinterstein'
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
const visible = role === 'tenant'
? threads.filter((t) => t.unitId === meineEinheit.id)
: threads
@@ -130,35 +78,14 @@ export default function Messages() {
useEffect(() => {
if (!active) return
dispatch({ type: 'markThreadRead', threadId: active.id, role })
markThreadRead(active.id)
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [active?.id, active?.messages.length, dispatch, role])
}, [active?.id, active?.messages.length, markThreadRead, role])
const send = () => {
const send = async () => {
if (!active || !draft.trim()) return
const text = draft.trim()
const autor = role === 'tenant' ? meinName : verwaltungsName
dispatch({
type: 'addMessage',
threadId: active.id,
message: {
id: `${active.id}-${Date.now()}`,
from: role,
author: autor,
text,
at: new Date().toLocaleString('de-DE'),
},
})
benachrichtigeGegenstelle({
benutzer,
vonRolle: role,
einheitId: active.unitId,
betreff:
role === 'tenant'
? `Neue Antwort im Mieterportal: ${active.subject}`
: `Antwort der Verwaltung: ${active.subject}`,
text: `Neue Nachricht von ${autor} in der Unterhaltung „${active.subject}“:\n\n„${text}“`,
})
await addMessage(active.id, text)
setDraft('')
}
+16 -43
View File
@@ -13,12 +13,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
nachrichtenText,
einheitLabel,
} from '../lib/mail'
import {
Select,
SelectContent,
@@ -52,45 +46,26 @@ const categories = [
]
function NewTicketDialog() {
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore()
const { addTicket, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('')
const [sende, setSende] = useState(false)
const submit = () => {
if (!title.trim()) return
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
dispatch({
type: 'addTicket',
ticket: {
id: `t-${Date.now()}`,
unitId,
tenant: mieterName,
title: title.trim(),
description: description.trim() || '–',
category,
priority,
status: 'offen',
createdAt: new Date().toLocaleDateString('de-DE'),
},
const submit = async () => {
if (!title.trim() || sende) return
setSende(true)
const err = await addTicket({
title: title.trim(),
description: description.trim() || '–',
category,
priority,
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
})
// Verwaltung per E-Mail benachrichtigen (fire-and-forget)
sendeBenachrichtigung(
verwaltungsAdressen(benutzer),
`Neue Schadensmeldung: ${title.trim()} (${einheitLabel(einheit)})`,
nachrichtenText(
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
`Wohnung: ${einheitLabel(einheit)}\n` +
`Meldung von: ${mieterName}\n` +
`Kategorie: ${category}\n` +
`Dringlichkeit: ${priority}\n\n` +
`${title.trim()}\n${description.trim() || '–'}`,
),
)
setSende(false)
if (err) return
setTitle('')
setDescription('')
setOpen(false)
@@ -158,7 +133,7 @@ function NewTicketDialog() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!title.trim()}>Meldung absenden</Button>
<Button onClick={submit} disabled={!title.trim() || sende}>{sende ? 'Wird gesendet …' : 'Meldung absenden'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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' ? (
<Select
value={t.status}
onValueChange={(v) =>
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
}
onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
>
<SelectTrigger className="h-8 w-40">
<SelectValue />
+204 -324
View File
@@ -1,240 +1,72 @@
/** Zentraler Store des Mieterportals – seit v1.3 komplett serverseitig
* (Node-API + PostgreSQL). Der Browser hält keine Mietdaten mehr im
* localStorage; die Sitzung läuft über ein HttpOnly-Cookie (8 h).
*
* Das Store-Interface bleibt absichtlich nahe an der bisherigen Form, damit
* die Seiten nur minimal angepasst werden mussten: Lesezugriff synchron über
* den State, Schreibzugriff über asynchrone Methoden (rufen die API auf und
* aktualisieren danach den lokalen State aus der Antwort/Neuladung).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
type ReactNode,
} from 'react'
import type {
Role,
Ticket,
TicketStatus,
Transaction,
DocumentItem,
Thread,
Message,
Benutzer,
BenutzerRolle,
RegistrierungsCode,
Unit,
Announcement,
} from '../types'
import {
units,
transactions,
tickets,
documents,
threads,
announcements,
DEMO_TENANT,
DEMO_UNIT_ID,
} from '../data/seed'
import {
sha256,
DEMO_PASSWORT,
ladeSession,
speichereSession,
beendeSession,
type Session,
} from '../lib/auth'
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
/** Demo-Benutzer (Passwort aller: 0815). */
export const demoBenutzer: Benutzer[] = [
{
id: 'b-joseph',
benutzername: 'j.hinterstein',
name: 'Joseph Hinterstein',
passwortHash: DEMO_HASH,
rolle: 'verwaltung',
email: 'joseph@hinterstein-immo.de',
einheitId: null,
aktiv: true,
erstelltAm: '01.03.2024',
},
{
id: 'b-anna',
benutzername: 'a.berger',
name: DEMO_TENANT,
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'anna.berger@example.de',
einheitId: DEMO_UNIT_ID,
aktiv: true,
erstelltAm: '01.03.2024',
letzterLogin: '02.10.2026, 09:12',
},
{
id: 'b-jonas',
benutzername: 'm.wolf',
name: 'Jonas Klein',
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'jonas.klein@example.de',
einheitId: 'rw-4',
aktiv: true,
erstelltAm: '15.08.2025',
},
]
/** Demo-Registrierungscode (Verwaltung kann weitere erzeugen). */
const demoCodes: RegistrierungsCode[] = [
{
code: 'MP-2026-0815',
einheitId: 'ap-2',
erstelltAm: '02.10.2026',
},
]
interface State {
tickets: Ticket[]
interface Bootstrap {
units: Unit[]
transactions: Transaction[]
documents: DocumentItem[]
tickets: Ticket[]
threads: Thread[]
announcements: Announcement[]
benutzer: Benutzer[]
codes: RegistrierungsCode[]
session: Session | null
tick: number
}
type Action =
| { type: 'addTicket'; ticket: Ticket }
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
| { type: 'addMessage'; threadId: string; message: Message }
| { type: 'newThread'; thread: Thread }
| { type: 'markThreadRead'; threadId: string; role: Role }
| { type: 'setSession'; session: Session | null }
| { type: 'registriert'; benutzer: Benutzer; code: string }
| { type: 'benutzerAngelegt'; benutzer: Benutzer }
| { type: 'benutzerAktiv'; id: string; aktiv: boolean }
| { type: 'passwortGesetzt'; id: string; hash: string }
| { type: 'einheitZugewiesen'; id: string; einheitId: string | null }
| { type: 'codesHinzugefuegt'; codes: RegistrierungsCode[] }
const initialState: State = {
tickets,
transactions,
documents,
threads,
benutzer: demoBenutzer,
codes: demoCodes,
session: ladeSession(),
tick: 0,
const LEER: Bootstrap = {
units: [],
transactions: [],
documents: [],
tickets: [],
threads: [],
announcements: [],
benutzer: [],
codes: [],
}
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
function loadState(): State {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
return { ...initialState, ...parsed, session: ladeSession() }
}
} catch {
/* ignore */
}
return initialState
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'addTicket':
return { ...state, tickets: [action.ticket, ...state.tickets] }
case 'setTicketStatus':
return {
...state,
tickets: state.tickets.map((t) =>
t.id === action.id ? { ...t, status: action.status } : t,
),
}
case 'addMessage':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? {
...th,
unreadByAdmin: action.message.from === 'tenant',
unreadByTenant: action.message.from === 'admin',
messages: [...th.messages, action.message],
}
: th,
),
}
case 'newThread':
return { ...state, threads: [action.thread, ...state.threads] }
case 'markThreadRead':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? action.role === 'tenant'
? { ...th, unreadByTenant: false }
: { ...th, unreadByAdmin: false }
: th,
),
}
case 'setSession':
return { ...state, session: action.session, tick: state.tick + 1 }
case 'registriert':
return {
...state,
benutzer: [...state.benutzer, action.benutzer],
codes: state.codes.map((c) =>
c.code === action.code ? { ...c, verwendetAm: new Date().toLocaleDateString('de-DE') } : c,
),
}
case 'benutzerAngelegt':
return { ...state, benutzer: [...state.benutzer, action.benutzer] }
case 'benutzerAktiv':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, aktiv: action.aktiv } : b,
),
}
case 'passwortGesetzt':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, passwortHash: action.hash } : b,
),
}
case 'einheitZugewiesen':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, einheitId: action.einheitId } : b,
),
}
case 'codesHinzugefuegt':
return { ...state, codes: [...action.codes, ...state.codes] }
default:
return state
}
}
function neuerCode(): string {
const teile = () =>
Array.from(crypto.getRandomValues(new Uint8Array(2)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
return `MP-${teile()}-${teile()}`
}
interface Store extends State {
dispatch: React.Dispatch<Action>
units: typeof units
announcements: typeof announcements
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
interface Store extends Bootstrap {
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
bereit: boolean
me: Benutzer | null
/** Abgeleitete Portal-Rolle für die Modul-Ansichten. */
role: Role
/** Einheit des angemeldeten Mieters (Demo-Fallback hs-7). */
meineEinheit: (typeof units)[number]
meineEinheit: Unit
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => void
logout: () => Promise<void>
registrieren: (
code: string,
benutzername: string,
@@ -242,6 +74,11 @@ interface Store extends State {
email: string,
passwort: string,
) => Promise<string | null>
addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null>
setTicketStatus: (id: string, status: TicketStatus) => Promise<void>
newThread: (subject: string, text: string, unitId?: string) => Promise<string | null>
addMessage: (threadId: string, text: string) => Promise<void>
markThreadRead: (threadId: string) => void
benutzerAnlegen: (
benutzername: string,
name: string,
@@ -250,136 +87,179 @@ interface Store extends State {
passwort: string,
einheitId: string | null,
) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => void
setAktiv: (id: string, aktiv: boolean) => Promise<void>
setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => void
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
}
const StoreContext = createContext<Store | null>(null)
export function StoreProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, undefined, loadState)
const [bereit, setBereit] = useState(false)
const [me, setMe] = useState<Benutzer | null>(null)
const [daten, setDaten] = useState<Bootstrap>(LEER)
/** Lädt alle Stammdaten neu (nach Login/Registrierung und nach Schreibaktionen). */
const ladeAlles = useCallback(async () => {
const b = await apiGet<Bootstrap>('/api/bootstrap')
setDaten(b)
}, [])
// Start: bestehende Session prüfen, dann Daten laden
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
const value = useMemo<Store>(() => {
const me =
(state.session &&
state.benutzer.find((b) => b.id === state.session!.benutzerId && b.aktiv)) ||
null
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
units.find((u) => u.id === DEMO_UNIT_ID)!
const login = async (benutzername: string, passwort: string) => {
const b = state.benutzer.find(
(x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase(),
)
if (!b) return 'Benutzername oder Passwort falsch.'
if (!b.aktiv) return 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.'
if ((await sha256(passwort)) !== b.passwortHash)
return 'Benutzername oder Passwort falsch.'
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
}
const logout = () => {
beendeSession()
dispatch({ type: 'setSession', session: null })
}
const registrieren = async (
code: string,
benutzername: string,
name: string,
email: string,
passwort: string,
) => {
const c = state.codes.find(
(x) => x.code.toUpperCase() === code.trim().toUpperCase() && !x.verwendetAm,
)
if (!c) return 'Der Registrierungscode ist ungültig oder bereits verwendet.'
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle: 'mieter',
email: email.trim(),
einheitId: c.einheitId,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
let aktiv = true
;(async () => {
try {
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
if (!aktiv) return
setMe(benutzer)
await ladeAlles()
} catch {
/* nicht angemeldet – Login-Seite zeigen */
} finally {
if (aktiv) setBereit(true)
}
dispatch({ type: 'registriert', benutzer: b, code: c.code })
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
})()
return () => {
aktiv = false
}
}, [ladeAlles])
const benutzerAnlegen = async (
benutzername: string,
name: string,
rolle: BenutzerRolle,
email: string,
passwort: string,
einheitId: string | null,
) => {
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle,
email: email.trim(),
einheitId: rolle === 'mieter' ? einheitId : null,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
daten.units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
daten.units.find((u) => u.id === DEMO_UNIT_ID) ??
daten.units[0]
const fehlerNachricht = (e: unknown): string =>
e instanceof ApiFehler ? e.message : 'Ein unerwarteter Fehler ist aufgetreten.'
const value = useMemo<Store>(() => ({
...daten,
bereit,
me,
role,
meineEinheit,
// ── Auth ────────────────────────────────────────────────
async login(benutzername, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/login', {
benutzername,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
dispatch({ type: 'benutzerAngelegt', benutzer: b })
return null
}
const setPasswort = async (id: string, passwort: string) => {
dispatch({ type: 'passwortGesetzt', id, hash: await sha256(passwort) })
}
const generiereCodes = (anzahl: number, einheitId: string | null) => {
const neu: RegistrierungsCode[] = Array.from({ length: anzahl }, () => ({
code: neuerCode(),
einheitId,
erstelltAm: new Date().toLocaleDateString('de-DE'),
},
async logout() {
try {
await apiPost('/api/auth/logout')
} catch {
/* Session ggf. bereits ungültig – trotzdem lokal abmelden */
}
setMe(null)
setDaten(LEER)
},
async registrieren(code, benutzername, name, email, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
code,
benutzername,
name,
email,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Schadensmeldungen ───────────────────────────────────
async addTicket(t) {
try {
await apiPost('/api/tickets', t)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setTicketStatus(id, status) {
const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status })
setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) }))
},
// ── Nachrichten ─────────────────────────────────────────
async newThread(subject, text, unitId) {
try {
const { thread } = await apiPost<{ thread: Thread }>('/api/threads', { subject, text, unitId })
setDaten((d) => ({ ...d, threads: [thread, ...d.threads.filter((t) => t.id !== thread.id)] }))
return thread.id
} catch (e) {
console.error(fehlerNachricht(e))
return null
}
},
async addMessage(threadId, text) {
const { thread } = await apiPost<{ thread: Thread }>(`/api/threads/${threadId}/messages`, { text })
setDaten((d) => ({ ...d, threads: d.threads.map((t) => (t.id === threadId ? thread : t)) }))
},
markThreadRead(threadId) {
// Optimistisch lokal markieren, serverseitig nachmelden
setDaten((d) => ({
...d,
threads: d.threads.map((t) =>
t.id === threadId
? role === 'tenant'
? { ...t, unreadByTenant: false }
: { ...t, unreadByAdmin: false }
: t,
),
}))
dispatch({ type: 'codesHinzugefuegt', codes: neu })
return neu
}
return {
...state,
dispatch,
units,
announcements,
me,
role,
meineEinheit,
login,
logout,
registrieren,
benutzerAnlegen,
setAktiv: (id, aktiv) => dispatch({ type: 'benutzerAktiv', id, aktiv }),
setPasswort,
weiseEinheitZu: (id, einheitId) => dispatch({ type: 'einheitZugewiesen', id, einheitId }),
generiereCodes,
}
}, [state])
apiPost(`/api/threads/${threadId}/read`).catch(() => {})
},
// ── Benutzerverwaltung (Verwaltung) ─────────────────────
async benutzerAnlegen(benutzername, name, rolle, email, passwort, einheitId) {
try {
await apiPost('/api/users', {
benutzername,
name,
rolle,
email,
einheitId,
passwortHash: await sha256(passwort),
})
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setAktiv(id, aktiv) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async setPasswort(id, passwort) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, {
passwortHash: await sha256(passwort),
})
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async weiseEinheitZu(id, einheitId) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async generiereCodes(anzahl, einheitId) {
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
await ladeAlles()
return codes
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
}
@@ -395,4 +275,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
})
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }
export { DEMO_PASSWORT }
+8
View File
@@ -9,6 +9,14 @@ export default defineConfig({
plugins: [inspectAttr(), react()],
server: {
port: 3000,
proxy: {
// Dev-Server: API-Anfragen an die lokale mp-api weiterreichen
// (Produktion: nginx des mp-portal-Containers proxiet /api/ an mp-api)
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
resolve: {
alias: {