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). **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`. 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 ## Entwicklung
```bash ```bash
npm install 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/ 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). 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 ./deploy.sh
``` ```
baut das Frontend, lädt das `dist/`-Paket per `pscp` auf den Server (Port 38592, baut das Frontend, lädt Paket (dist + compose + nginx-Template + `server/`) per `pscp`
Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Container auf den Server (Port 38592, Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und
`mp-portal` neu. TLS/Subdomain liefert der Nginx Proxy Manager (npm_default-Netz). 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`. 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: 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` 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 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 Verwaltung bei neuen Schadensmeldungen und die jeweilige Gegenstelle bei
Nachrichten – Versand fire-and-forget, ein Ausfall der Mail-API beeinträchtigt Nachrichten – Versand fire-and-forget, ein Ausfall der Mail-API beeinträchtigt
das Portal nicht. das Portal nicht.
+6 -5
View File
@@ -2,15 +2,16 @@
"""Hocks Mieterportal – Beispieldaten-Generator. """Hocks Mieterportal – Beispieldaten-Generator.
Erzeugt deterministisch die Demo-Daten des Portals und schreibt sie nach Erzeugt deterministisch die Demo-Daten des Portals und schreibt sie nach
``src/data/generated.json`` (Units + Transaktionen). Die handschriftlichen ``server/seed/generated.json`` (Units + Transaktionen) – die Datenbank wird
Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen) liegen weiterhin in daraus beim ersten Start des mp-api-Containers gespeist. Die handschriftlichen
``src/data/seed.ts``. Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen, Benutzer) liegen in
``server/src/seed-data.js``.
Aufruf: python beispieldaten.py (Standard-Seed 20261002) Aufruf: python beispieldaten.py (Standard-Seed 20261002)
python beispieldaten.py 42 (eigener Seed zum Experimentieren) python beispieldaten.py 42 (eigener Seed zum Experimentieren)
Achtung: Der Demo-Mieter „Anna Berger" (Whg. Hauptstraße 12 Nr. 7) und die 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 import json
@@ -115,7 +116,7 @@ def main() -> None:
"units": units, "units": units,
"transactions": gen_transactions(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") out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{out} geschrieben: {data['meta']['units']} Wohnungen " print(f"{out} geschrieben: {data['meta']['units']} Wohnungen "
f"({data['meta']['rented']} vermietet), {len(data['transactions'])} Buchungen") f"({data['meta']['rented']} vermietet), {len(data['transactions'])} Buchungen")
+15 -8
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env bash #!/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 # 1. npm run build 2. Paket (dist + compose + nginx + server) hochladen
# 3. Entpacken nach /opt/mieterportal, Container mp-portal neu starten # 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 # Aufruf: ./deploy.sh
set -euo pipefail set -euo pipefail
@@ -24,23 +26,28 @@ rm -rf tmp-deploy && mkdir -p tmp-deploy/docker/templates
cp -r dist tmp-deploy/dist cp -r dist tmp-deploy/dist
cp docker-compose.yml tmp-deploy/ cp docker-compose.yml tmp-deploy/
cp docker/templates/default.conf.template tmp-deploy/docker/templates/ 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 . tar -czf mp-dist.tar.gz -C tmp-deploy .
rm -rf tmp-deploy rm -rf tmp-deploy
"$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \ "$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \
mp-dist.tar.gz "$SERVER":/tmp/mp-dist.tar.gz mp-dist.tar.gz "$SERVER":/tmp/mp-dist.tar.gz
rm -f 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" " "$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
mkdir -p $REMOTE_DIR && mkdir -p $REMOTE_DIR &&
tar -xzf /tmp/mp-dist.tar.gz -C $REMOTE_DIR && tar -xzf /tmp/mp-dist.tar.gz -C $REMOTE_DIR &&
rm -f /tmp/mp-dist.tar.gz && 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" echo "==> 4/4 Status prüfen"
sleep 5
"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" " "$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
docker ps --filter name=mp-portal --format 'table {{.Names}}\t{{.Status}}' && 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/ | grep -o 'index-[^\"]*\.js' | head -1 &&
curl -s https://mieterportal.hocks.eu/api/healthz
" "
echo
echo "Fertig: https://mieterportal.hocks.eu" 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). # 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: services:
mp-portal: mp-portal:
@@ -13,9 +22,46 @@ services:
- ./docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro - ./docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro
env_file: env_file:
- portal.env - 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: networks:
- npm_default - npm_default
networks: networks:
npm_default: npm_default:
external: true external: true
volumes:
mp_pgdata:
+10
View File
@@ -33,6 +33,16 @@ server {
proxy_read_timeout 15s; 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 # SPA-Fallback für React Router
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+89 -54
View File
@@ -1,6 +1,6 @@
# Kimi-Kontext: Hinterstein Immobilien · Mieterportal (mieterportal.hocks.eu) # 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. > Bitte bei größeren Änderungen am Projekt aktualisieren.
> Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert. > 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 **50 Wohnungen in 3 Häusern** (Hauptstraße 12 · Ringweg 3 · Am Park 5). Analog zum
Schulportal: alle Mietangelegenheiten digital an einem Ort. Schulportal: alle Mietangelegenheiten digital an einem Ort.
**Authentifizierung (echtes Login seit v1.1):** Startseite `/login` mit **Datenhaltung (seit v1.3):** zentral serverseitig in **PostgreSQL** über eine
Login-Pflicht (Route-Guard `RequireAuth` in `App.tsx` leitet unangemeldete Besucher **Node-API** (Express) – kein localStorage mehr für Mietdaten. Mehrere Nutzer
auf `/login` um). **Gruppen:** `verwaltung` und `mieter`. Mieter können sich über sehen denselben Datenstand in Echtzeit; Rechte werden serverseitig erzwungen
einen **Registrierungscode** selbst registrieren (`/registrieren`); Codes legt die (Mieter nur eigene Wohnung, Verwaltung alles).
Verwaltung in der Benutzerverwaltung an (einmalig nutzbar, Wohnungsbezug).
Passwörter als **SHA-256-Hash** in localStorage, Session 8 h **Authentifizierung:** Login-Pflicht auf `/login` (Route-Guard in `App.tsx`).
(Key `mp_benutzer_session`). Demo-Zugänge (Passwort `0815`): Gruppen `verwaltung` und `mieter`; Mieter registrieren sich per einmaligem
`j.hinterstein` (Verwaltung, Joseph Hinterstein), `a.berger` (Mieterin, **Registrierungscode** (`/registrieren`). Passwörter als SHA-256-Hash (im Browser
Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-0815`. 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:** **Die 6 Module:**
@@ -43,12 +46,14 @@ Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-08
| Teil | Technologie | | Teil | Technologie |
|---|---| |---|---|
| Frontend | React 18 + TypeScript, Vite 7, Tailwind v3.4 + shadcn/ui (Radix), React Router 7 (BrowserRouter), Recharts | | 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`) | | State | Store-Kontext im Browser, **alle Daten serverseitig** (kein localStorage mehr); Ladezustand `bereit`, Schreibzugriff async |
| Daten | `src/data/seed.ts` (handschriftlich) + `src/data/generated.json` (maschinell via `beispieldaten.py`) | | API | `server/`: Node 20 + Express + pg, Session-Cookie, Rollenrechte, serverseitige Mail-Benachrichtigungen |
| Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` | | 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` | | 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` | | 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` 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) | | 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) | | Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
## 3. Wichtige Dateien ## 3. Wichtige Dateien
@@ -59,23 +64,26 @@ ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.).
| Datei | Inhalt | | Datei | Inhalt |
|---|---| |---|---|
| `src/store/store.tsx` | Store-Provider, Reducer, Actions (setRole/addTicket/setTicketStatus/addMessage/newThread/markThreadRead), `fmtEUR` | | `src/store/store.tsx` | Store-Kontext: lädt `/api/bootstrap`, stellt Collections + `me`/`role`/`meineEinheit` bereit, async-Methoden für alle Schreibaktionen, `fmtEUR` |
| `src/data/seed.ts` | Tickets, Dokumente, Nachrichten-Threads, Ankündigungen + Re-Export units/transactions aus generated.json | | `src/lib/api.ts` | Fetch-Client (same-origin, JSON, `ApiFehler` mit deutscher Meldung) |
| `src/data/generated.json` | 50 Wohnungen + 552 Buchungen, erzeugt von `beispieldaten.py` (Seed 20261002) | | `src/lib/auth.ts` | SHA-256 für Passwörter, `DEMO_PASSWORT` |
| `beispieldaten.py` | Deterministischer Generator: `python beispieldaten.py [seed]` | | `server/src/index.js` | Express-API: Auth (Login/Logout/me/Register), Bootstrap, Tickets, Threads/Messages, Benutzerverwaltung, Codes, `/api/healthz` |
| `src/types/index.ts` | Alle Typen (Unit, Transaction, Ticket, DocumentItem, Thread, Announcement) | | `server/src/db.js` | pg-Pool + idempotentes Schema (11 Tabellen, snake_case) |
| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle | | `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/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) | | `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-Formular + Registrierung-Link + Demo-Einstiege | | `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login + Registrierung + 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) | | `src/pages/BenutzerVerwaltung.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer anlegen/pflegen, Registrierungscodes erzeugen |
| `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` | | `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}` |
| `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes | | `docker-compose.yml` | Stack: `mp-portal` (nginx), `mp-api` (Node, Build ./server), `mp-db` (PostgreSQL 16, Volume mp_pgdata), Netz npm_default |
| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow | | `deploy.sh` | Deploy-Workflow: Build, Paket (dist + compose + nginx + server), Upload, `docker compose up -d --build`, Health-Check |
| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header | | `mail-server/` | Postfix-Relay + schlanke Mail-API (Muster Schulportal, angepasst) – separater Stack unter /opt/mp-mail |
| `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) |
## 4. Deploy-Workflow (bewährt, aus Schulportal übernommen) ## 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. - 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** - **`portal.env` auf dem Server** (gitignored, liegt nur dort): liefert
(liefert `MAIL_API_TOKEN` für den nginx-envsubst). Einmalig angelegt: `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` `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 - **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`. 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/` - **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 ## 5. Zugänge & Konfiguration
- **Demo-Zugänge (Passwort `0815`):** `j.hinterstein` (Verwaltung), `a.berger` - **Demo-Zugänge (Passwort `0815`):** `j.hinterstein` (Verwaltung), `a.berger`
(Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: (Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter, Ringweg 3 Whg. 4).
`MP-2026-0815`. Passwörter werden als SHA-256-Hash gespeichert; Store-Reset über Demo-Registrierungscode: `MP-2026-0815`. Passwörter liegen als SHA-256-Hash
Browser-DevTools (localStorage-Keys `mp_benutzer`, `mp_benutzer_session`, in der users-Tabelle; Sitzung als HttpOnly-Cookie (Tabelle sessions).
`hinterstein-mieterportal-v2` löschen). 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 - **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt auf dem Server in
`/opt/mp-mail/mail.env` (Vorlage `mail.env.example` im Repo). `/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 - **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`). 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. 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 - **Mail-Benachrichtigungen (seit v1.2 nginx-Proxy, seit v1.3 serverseitig):** Der
(`docker/templates/default.conf.template`, envsubst) proxiet ausschließlich Portal-nginx proxiet `POST /api/mail/send` an `http://mp-mail-api:8080/send` mit
`POST /api/mail/send` an `http://mp-mail-api:8080/send` und setzt das Token Token aus `portal.env` (403 für alles außer POST). **Versand aus der Node-API**
aus `/opt/mieterportal/portal.env` (chmod 600, **nicht im Repo**, gitignored). (`server/src/mail.js`, fire-and-forget): neue Schadensmeldung → alle aktiven
Das Frontend (`src/lib/mail.ts`) sendet fire-and-forget: neue Schadensmeldung → Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle (Mieter ↔ Verwaltung,
alle aktiven Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle Auflösung über Wohnungsbezug). E2E verifiziert: Zustellung an axel@hocks.eu
(Mieter ↔ Verwaltung, Empfänger über Wohnungsbezug). E2E verifiziert: mit Postfix status=sent (02.10., 10:42 Uhr).
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.
## 6. Konventionen & Besonderheiten ## 6. Konventionen & Besonderheiten
@@ -160,8 +175,12 @@ tar -czf mp-dist.tar.gz -C dist .
## 7. Testing ## 7. Testing
- `npm run build` (`tsc -b`) ist die echte Typprüfung; `npx tsc --noEmit` prüft wegen - `npm run build` (`tsc -b`) ist die echte Typprüfung; `npx tsc --noEmit` prüft wegen
Solution-tsconfig ggf. nichts. Solution-tsconfig ggf. nichts. Server-Syntax: `node --check server/src/*.js`.
- Headless-Screenshots (Dark Mode = Systempräferenz): - 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"` `"/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=... --window-size=WxH "URL"`
- Screenshots ausgeliefert nach `preview/`. - 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). neuen Schadensmeldungen (→ Verwaltung) und Nachrichten/Antworten (→ Gegenstelle).
E2E-Versand live verifiziert (versandt: true), GET auf den Endpunkt 403. 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:** **Offen:**
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge - Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen. erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
- Demo-Empfängeradressen durch echte ersetzen, sobald bekannt. - Demo-Empfängeradressen der Mieter (example.de) durch echte ersetzen, sobald bekannt.
- Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage, - Sicherheit für Produktion: Passwort-Hash serverseitig salten (bcrypt/argon2), CSRF-Token,
Mehrseitigkeit für 50 echte Mietverhältnisse. 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 ## 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 Messages from './pages/Messages'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung' 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 }) { function RequireAuth({ children }: { children: React.ReactElement }) {
const { me } = useStore() const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (!me) return <Navigate to="/login" replace /> if (!me) return <Navigate to="/login" replace />
return children return children
} }
function LoginGate() { function LoginGate() {
const { me } = useStore() const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (me) return <Navigate to="/" replace /> if (me) return <Navigate to="/" replace />
return <Login /> 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. */ /** Passwort-Hashing (wie bisher: SHA-256 im Browser, Vergleich serverseitig).
*
import type { Benutzer, BenutzerRolle } from '../types' * Die Sitzung selbst liegt seit v1.3 serverseitig (HttpOnly-Cookie,
* Tabelle sessions) – es gibt nichts mehr im localStorage zu verwalten.
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'
/** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */ /** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */
export const DEMO_PASSWORT = '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')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .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> </select>
<Button <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" className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
> >
<RefreshCcw className="h-4 w-4" /> Codes erzeugen <RefreshCcw className="h-4 w-4" /> Codes erzeugen
+13 -86
View File
@@ -13,71 +13,21 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useStore } from '../store/store' 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 }) { 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 [open, setOpen] = useState(false)
const [subject, setSubject] = useState('') const [subject, setSubject] = useState('')
const [text, setText] = useState('') const [text, setText] = useState('')
const verwaltungsName = 'Joseph Hinterstein' const submit = async () => {
const submit = () => {
if (!subject.trim() || !text.trim()) return if (!subject.trim() || !text.trim()) return
const id = `m-${Date.now()}` const id = await newThread(
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1' subject.trim(),
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName text.trim(),
dispatch({ role === 'tenant' ? meineEinheit.id : 'hs-1',
type: 'newThread', )
thread: { if (!id) return
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()}“`,
})
setSubject('') setSubject('')
setText('') setText('')
setOpen(false) setOpen(false)
@@ -117,9 +67,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
} }
export default function Messages() { export default function Messages() {
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore() const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
const meinName = me?.name ?? 'Mieter'
const verwaltungsName = 'Joseph Hinterstein'
const visible = role === 'tenant' const visible = role === 'tenant'
? threads.filter((t) => t.unitId === meineEinheit.id) ? threads.filter((t) => t.unitId === meineEinheit.id)
: threads : threads
@@ -130,35 +78,14 @@ export default function Messages() {
useEffect(() => { useEffect(() => {
if (!active) return if (!active) return
dispatch({ type: 'markThreadRead', threadId: active.id, role }) markThreadRead(active.id)
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) 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 if (!active || !draft.trim()) return
const text = draft.trim() const text = draft.trim()
const autor = role === 'tenant' ? meinName : verwaltungsName await addMessage(active.id, text)
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}“`,
})
setDraft('') setDraft('')
} }
+12 -39
View File
@@ -13,12 +13,6 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
nachrichtenText,
einheitLabel,
} from '../lib/mail'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -52,45 +46,26 @@ const categories = [
] ]
function NewTicketDialog() { function NewTicketDialog() {
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore() const { addTicket, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär') const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel') const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [sende, setSende] = useState(false)
const submit = () => { const submit = async () => {
if (!title.trim()) return if (!title.trim() || sende) return
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1' setSende(true)
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit const err = await addTicket({
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
dispatch({
type: 'addTicket',
ticket: {
id: `t-${Date.now()}`,
unitId,
tenant: mieterName,
title: title.trim(), title: title.trim(),
description: description.trim() || '–', description: description.trim() || '–',
category, category,
priority, priority,
status: 'offen', unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
createdAt: new Date().toLocaleDateString('de-DE'),
},
}) })
// Verwaltung per E-Mail benachrichtigen (fire-and-forget) setSende(false)
sendeBenachrichtigung( if (err) return
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() || '–'}`,
),
)
setTitle('') setTitle('')
setDescription('') setDescription('')
setOpen(false) setOpen(false)
@@ -158,7 +133,7 @@ function NewTicketDialog() {
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <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> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -166,7 +141,7 @@ function NewTicketDialog() {
} }
export default function Tickets() { 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 [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
const visible = useMemo( const visible = useMemo(
@@ -247,9 +222,7 @@ export default function Tickets() {
{role === 'admin' ? ( {role === 'admin' ? (
<Select <Select
value={t.status} value={t.status}
onValueChange={(v) => onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
}
> >
<SelectTrigger className="h-8 w-40"> <SelectTrigger className="h-8 w-40">
<SelectValue /> <SelectValue />
+200 -320
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 { import {
createContext, createContext,
useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useReducer, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import type { import type {
Role, Role,
Ticket, Ticket,
TicketStatus,
Transaction, Transaction,
DocumentItem, DocumentItem,
Thread, Thread,
Message,
Benutzer, Benutzer,
BenutzerRolle, BenutzerRolle,
RegistrierungsCode, RegistrierungsCode,
Unit,
Announcement,
} from '../types' } from '../types'
import { import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
units, import { sha256, DEMO_PASSWORT } from '../lib/auth'
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'
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). */ interface Bootstrap {
export const demoBenutzer: Benutzer[] = [ units: Unit[]
{
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[]
transactions: Transaction[] transactions: Transaction[]
documents: DocumentItem[] documents: DocumentItem[]
tickets: Ticket[]
threads: Thread[] threads: Thread[]
announcements: Announcement[]
benutzer: Benutzer[] benutzer: Benutzer[]
codes: RegistrierungsCode[] codes: RegistrierungsCode[]
session: Session | null
tick: number
} }
type Action = const LEER: Bootstrap = {
| { type: 'addTicket'; ticket: Ticket } units: [],
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] } transactions: [],
| { type: 'addMessage'; threadId: string; message: Message } documents: [],
| { type: 'newThread'; thread: Thread } tickets: [],
| { type: 'markThreadRead'; threadId: string; role: Role } threads: [],
| { type: 'setSession'; session: Session | null } announcements: [],
| { type: 'registriert'; benutzer: Benutzer; code: string } benutzer: [],
| { type: 'benutzerAngelegt'; benutzer: Benutzer } codes: [],
| { 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 STORAGE_KEY = 'hinterstein-mieterportal-v2' interface Store extends Bootstrap {
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
function loadState(): State { bereit: boolean
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). */
me: Benutzer | null me: Benutzer | null
/** Abgeleitete Portal-Rolle für die Modul-Ansichten. */
role: Role role: Role
/** Einheit des angemeldeten Mieters (Demo-Fallback hs-7). */ meineEinheit: Unit
meineEinheit: (typeof units)[number]
login: (benutzername: string, passwort: string) => Promise<string | null> login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => void logout: () => Promise<void>
registrieren: ( registrieren: (
code: string, code: string,
benutzername: string, benutzername: string,
@@ -242,6 +74,11 @@ interface Store extends State {
email: string, email: string,
passwort: string, passwort: string,
) => Promise<string | null> ) => 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: ( benutzerAnlegen: (
benutzername: string, benutzername: string,
name: string, name: string,
@@ -250,136 +87,179 @@ interface Store extends State {
passwort: string, passwort: string,
einheitId: string | null, einheitId: string | null,
) => Promise<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> setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => void weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[] generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
} }
const StoreContext = createContext<Store | null>(null) const StoreContext = createContext<Store | null>(null)
export function StoreProvider({ children }: { children: ReactNode }) { 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(() => { useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) let aktiv = true
}, [state]) ;(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)
}
})()
return () => {
aktiv = false
}
}, [ladeAlles])
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 role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit = const meineEinheit =
units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ?? daten.units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
units.find((u) => u.id === DEMO_UNIT_ID)! daten.units.find((u) => u.id === DEMO_UNIT_ID) ??
daten.units[0]
const login = async (benutzername: string, passwort: string) => { const fehlerNachricht = (e: unknown): string =>
const b = state.benutzer.find( e instanceof ApiFehler ? e.message : 'Ein unerwarteter Fehler ist aufgetreten.'
(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 = () => { const value = useMemo<Store>(() => ({
beendeSession() ...daten,
dispatch({ type: 'setSession', session: null }) bereit,
}
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'),
}
dispatch({ type: 'registriert', benutzer: b, code: c.code })
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
}
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'),
}
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'),
}))
dispatch({ type: 'codesHinzugefuegt', codes: neu })
return neu
}
return {
...state,
dispatch,
units,
announcements,
me, me,
role, role,
meineEinheit, meineEinheit,
login, // ── Auth ────────────────────────────────────────────────
logout, async login(benutzername, passwort) {
registrieren, try {
benutzerAnlegen, const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/login', {
setAktiv: (id, aktiv) => dispatch({ type: 'benutzerAktiv', id, aktiv }), benutzername,
setPasswort, passwortHash: await sha256(passwort),
weiseEinheitZu: (id, einheitId) => dispatch({ type: 'einheitZugewiesen', id, einheitId }), })
generiereCodes, setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
} }
}, [state]) },
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,
),
}))
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> return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
} }
@@ -395,4 +275,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
currency: 'EUR', 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()], plugins: [inspectAttr(), react()],
server: { server: {
port: 3000, 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: { resolve: {
alias: { alias: {