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:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 |
@@ -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"]
|
||||||
Generated
+982
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}`
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user