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

- server/: Express-API (Auth, Bootstrap, Tickets, Threads, Benutzerverwaltung, Codes)
  mit pg-Schema (11 Tabellen), idempotem Seed (50 Wohnungen, 552 Buchungen)
- mp-db (PostgreSQL 16, Volume mp_pgdata) + mp-api im Compose-Stack,
  nginx proxiet /api/ an mp-api; deploy.sh baut API-Image auf dem Server
- Frontend: Store laedt /api/bootstrap, Schreibzugriff async, kein localStorage
  mehr (ausser Theme), Ladebildschirm beim Session-Check, vite-Proxy fuer Dev
- Rollenrechte serverseitig: Mieter nur eigene Wohnung (getestet, 403-Verweis)
- Mail-Benachrichtigungen komplett serverseitig (server/src/mail.js)
- beispieldaten.py schreibt server/seed/generated.json; Verwaltungs-Mail = axel@hocks.eu
- E2E live verifiziert: Login, Scoping, Ticket + Zustellung (status=sent),
  Registrierung, Codes; Testdaten bereinigt; Doku nachgefuehrt
This commit is contained in:
2026-10-02 12:54:44 +02:00
parent 5503ce5fc5
commit 311153b3c3
26 changed files with 8873 additions and 894 deletions
+54
View File
@@ -0,0 +1,54 @@
/** Dünner Fetch-Client für die Portal-API (gleicher Ursprung, Session-Cookie). */
export class ApiFehler extends Error {
status: number
constructor(status: number, nachricht: string) {
super(nachricht)
this.status = status
}
}
interface Antwort<T> {
json: T | null
status: number
}
async function anfrage<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<Antwort<T>> {
let res: Response
try {
res = await fetch(pfad, {
credentials: 'same-origin',
...init,
headers: {
...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
})
} catch {
throw new ApiFehler(0, 'Keine Verbindung zum Server.')
}
let daten: T | null = null
try {
daten = (await res.json()) as T
} catch {
/* leerer Body */
}
return { json: daten, status: res.status }
}
/** Führt eine API-Anfrage aus und liefert Daten oder eine deutschsprachige Fehlermeldung. */
export async function api<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<T> {
const { json: daten, status } = await anfrage<T>(pfad, init, json)
if (status >= 400 || daten === null) {
const fehler =
(daten as { fehler?: string } | null)?.fehler ??
(status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${status}).`)
throw new ApiFehler(status, fehler)
}
return daten
}
export const apiGet = <T>(pfad: string) => api<T>(pfad)
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
+5 -46
View File
@@ -1,10 +1,8 @@
/** Authentifizierung + Session, Muster aus dem Schulportal. */
import type { Benutzer, BenutzerRolle } from '../types'
export const SESSION_KEY = 'mp_benutzer_session'
export const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 // 8 h
export const BENUTZER_CHANGED_EVENT = 'mp-benutzer-changed'
/** Passwort-Hashing (wie bisher: SHA-256 im Browser, Vergleich serverseitig).
*
* Die Sitzung selbst liegt seit v1.3 serverseitig (HttpOnly-Cookie,
* Tabelle sessions) – es gibt nichts mehr im localStorage zu verwalten.
*/
/** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */
export const DEMO_PASSWORT = '0815'
@@ -15,42 +13,3 @@ export async function sha256(text: string): Promise<string> {
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export interface Session {
benutzerId: string
rolle: BenutzerRolle
bis: number
}
export function ladeSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY)
if (!raw) return null
const s = JSON.parse(raw) as Session
if (s.bis < Date.now()) {
localStorage.removeItem(SESSION_KEY)
return null
}
return s
} catch {
return null
}
}
export function speichereSession(benutzerId: string, rolle: BenutzerRolle) {
const s: Session = { benutzerId, rolle, bis: Date.now() + SESSION_DAUER_MS }
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
}
export function beendeSession() {
localStorage.removeItem(SESSION_KEY)
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
}
export function aktuellerBenutzer(alle: Benutzer[]): Benutzer | null {
const s = ladeSession()
if (!s) return null
const b = alle.find((x) => x.id === s.benutzerId && x.aktiv)
return b ?? null
}
-64
View File
@@ -1,64 +0,0 @@
/** E-Mail-Benachrichtigungen über die Portal-Mail-API.
*
* Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom
* Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080)
* weitergereicht und dabei serverseitig mit dem API-Token authentifiziert.
* Das Token liegt also weder im Repo noch im JavaScript.
*
* Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline,
* API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet
* nur in der Browser-Konsole.
*/
import type { Benutzer } from '../types'
const ENDPUNKT = '/api/mail/send'
export async function sendeMail(an: string, betreff: string, text: string): Promise<boolean> {
try {
const res = await fetch(ENDPUNKT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: an, subject: betreff, text }),
})
return res.ok
} catch {
return false
}
}
/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */
export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) {
const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean)
for (const adresse of adressen) {
sendeMail(adresse, betreff, text).then((ok) => {
if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`)
})
}
}
const FUSSZEILE =
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
export function nachrichtenText(koerper: string): string {
return `${koerper}${FUSSZEILE}`
}
/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */
export function verwaltungsAdressen(benutzer: Benutzer[]): string[] {
return benutzer
.filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email)
.map((b) => b.email)
}
/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */
export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] {
return benutzer
.filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email)
.map((b) => b.email)
}
/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */
export function einheitLabel(einheit: { building: string; number: string }): string {
return `${einheit.building}, Whg. ${einheit.number}`
}