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:
@@ -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. */
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user