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
+204 -324
View File
@@ -1,240 +1,72 @@
/** Zentraler Store des Mieterportals – seit v1.3 komplett serverseitig
* (Node-API + PostgreSQL). Der Browser hält keine Mietdaten mehr im
* localStorage; die Sitzung läuft über ein HttpOnly-Cookie (8 h).
*
* Das Store-Interface bleibt absichtlich nahe an der bisherigen Form, damit
* die Seiten nur minimal angepasst werden mussten: Lesezugriff synchron über
* den State, Schreibzugriff über asynchrone Methoden (rufen die API auf und
* aktualisieren danach den lokalen State aus der Antwort/Neuladung).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
type ReactNode,
} from 'react'
import type {
Role,
Ticket,
TicketStatus,
Transaction,
DocumentItem,
Thread,
Message,
Benutzer,
BenutzerRolle,
RegistrierungsCode,
Unit,
Announcement,
} from '../types'
import {
units,
transactions,
tickets,
documents,
threads,
announcements,
DEMO_TENANT,
DEMO_UNIT_ID,
} from '../data/seed'
import {
sha256,
DEMO_PASSWORT,
ladeSession,
speichereSession,
beendeSession,
type Session,
} from '../lib/auth'
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
/** Demo-Benutzer (Passwort aller: 0815). */
export const demoBenutzer: Benutzer[] = [
{
id: 'b-joseph',
benutzername: 'j.hinterstein',
name: 'Joseph Hinterstein',
passwortHash: DEMO_HASH,
rolle: 'verwaltung',
email: 'joseph@hinterstein-immo.de',
einheitId: null,
aktiv: true,
erstelltAm: '01.03.2024',
},
{
id: 'b-anna',
benutzername: 'a.berger',
name: DEMO_TENANT,
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'anna.berger@example.de',
einheitId: DEMO_UNIT_ID,
aktiv: true,
erstelltAm: '01.03.2024',
letzterLogin: '02.10.2026, 09:12',
},
{
id: 'b-jonas',
benutzername: 'm.wolf',
name: 'Jonas Klein',
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'jonas.klein@example.de',
einheitId: 'rw-4',
aktiv: true,
erstelltAm: '15.08.2025',
},
]
/** Demo-Registrierungscode (Verwaltung kann weitere erzeugen). */
const demoCodes: RegistrierungsCode[] = [
{
code: 'MP-2026-0815',
einheitId: 'ap-2',
erstelltAm: '02.10.2026',
},
]
interface State {
tickets: Ticket[]
interface Bootstrap {
units: Unit[]
transactions: Transaction[]
documents: DocumentItem[]
tickets: Ticket[]
threads: Thread[]
announcements: Announcement[]
benutzer: Benutzer[]
codes: RegistrierungsCode[]
session: Session | null
tick: number
}
type Action =
| { type: 'addTicket'; ticket: Ticket }
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
| { type: 'addMessage'; threadId: string; message: Message }
| { type: 'newThread'; thread: Thread }
| { type: 'markThreadRead'; threadId: string; role: Role }
| { type: 'setSession'; session: Session | null }
| { type: 'registriert'; benutzer: Benutzer; code: string }
| { type: 'benutzerAngelegt'; benutzer: Benutzer }
| { type: 'benutzerAktiv'; id: string; aktiv: boolean }
| { type: 'passwortGesetzt'; id: string; hash: string }
| { type: 'einheitZugewiesen'; id: string; einheitId: string | null }
| { type: 'codesHinzugefuegt'; codes: RegistrierungsCode[] }
const initialState: State = {
tickets,
transactions,
documents,
threads,
benutzer: demoBenutzer,
codes: demoCodes,
session: ladeSession(),
tick: 0,
const LEER: Bootstrap = {
units: [],
transactions: [],
documents: [],
tickets: [],
threads: [],
announcements: [],
benutzer: [],
codes: [],
}
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
function loadState(): State {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
return { ...initialState, ...parsed, session: ladeSession() }
}
} catch {
/* ignore */
}
return initialState
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'addTicket':
return { ...state, tickets: [action.ticket, ...state.tickets] }
case 'setTicketStatus':
return {
...state,
tickets: state.tickets.map((t) =>
t.id === action.id ? { ...t, status: action.status } : t,
),
}
case 'addMessage':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? {
...th,
unreadByAdmin: action.message.from === 'tenant',
unreadByTenant: action.message.from === 'admin',
messages: [...th.messages, action.message],
}
: th,
),
}
case 'newThread':
return { ...state, threads: [action.thread, ...state.threads] }
case 'markThreadRead':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? action.role === 'tenant'
? { ...th, unreadByTenant: false }
: { ...th, unreadByAdmin: false }
: th,
),
}
case 'setSession':
return { ...state, session: action.session, tick: state.tick + 1 }
case 'registriert':
return {
...state,
benutzer: [...state.benutzer, action.benutzer],
codes: state.codes.map((c) =>
c.code === action.code ? { ...c, verwendetAm: new Date().toLocaleDateString('de-DE') } : c,
),
}
case 'benutzerAngelegt':
return { ...state, benutzer: [...state.benutzer, action.benutzer] }
case 'benutzerAktiv':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, aktiv: action.aktiv } : b,
),
}
case 'passwortGesetzt':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, passwortHash: action.hash } : b,
),
}
case 'einheitZugewiesen':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, einheitId: action.einheitId } : b,
),
}
case 'codesHinzugefuegt':
return { ...state, codes: [...action.codes, ...state.codes] }
default:
return state
}
}
function neuerCode(): string {
const teile = () =>
Array.from(crypto.getRandomValues(new Uint8Array(2)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
return `MP-${teile()}-${teile()}`
}
interface Store extends State {
dispatch: React.Dispatch<Action>
units: typeof units
announcements: typeof announcements
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
interface Store extends Bootstrap {
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
bereit: boolean
me: Benutzer | null
/** Abgeleitete Portal-Rolle für die Modul-Ansichten. */
role: Role
/** Einheit des angemeldeten Mieters (Demo-Fallback hs-7). */
meineEinheit: (typeof units)[number]
meineEinheit: Unit
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => void
logout: () => Promise<void>
registrieren: (
code: string,
benutzername: string,
@@ -242,6 +74,11 @@ interface Store extends State {
email: string,
passwort: string,
) => Promise<string | null>
addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null>
setTicketStatus: (id: string, status: TicketStatus) => Promise<void>
newThread: (subject: string, text: string, unitId?: string) => Promise<string | null>
addMessage: (threadId: string, text: string) => Promise<void>
markThreadRead: (threadId: string) => void
benutzerAnlegen: (
benutzername: string,
name: string,
@@ -250,136 +87,179 @@ interface Store extends State {
passwort: string,
einheitId: string | null,
) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => void
setAktiv: (id: string, aktiv: boolean) => Promise<void>
setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => void
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
}
const StoreContext = createContext<Store | null>(null)
export function StoreProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, undefined, loadState)
const [bereit, setBereit] = useState(false)
const [me, setMe] = useState<Benutzer | null>(null)
const [daten, setDaten] = useState<Bootstrap>(LEER)
/** Lädt alle Stammdaten neu (nach Login/Registrierung und nach Schreibaktionen). */
const ladeAlles = useCallback(async () => {
const b = await apiGet<Bootstrap>('/api/bootstrap')
setDaten(b)
}, [])
// Start: bestehende Session prüfen, dann Daten laden
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
const value = useMemo<Store>(() => {
const me =
(state.session &&
state.benutzer.find((b) => b.id === state.session!.benutzerId && b.aktiv)) ||
null
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
units.find((u) => u.id === DEMO_UNIT_ID)!
const login = async (benutzername: string, passwort: string) => {
const b = state.benutzer.find(
(x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase(),
)
if (!b) return 'Benutzername oder Passwort falsch.'
if (!b.aktiv) return 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.'
if ((await sha256(passwort)) !== b.passwortHash)
return 'Benutzername oder Passwort falsch.'
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
}
const logout = () => {
beendeSession()
dispatch({ type: 'setSession', session: null })
}
const registrieren = async (
code: string,
benutzername: string,
name: string,
email: string,
passwort: string,
) => {
const c = state.codes.find(
(x) => x.code.toUpperCase() === code.trim().toUpperCase() && !x.verwendetAm,
)
if (!c) return 'Der Registrierungscode ist ungültig oder bereits verwendet.'
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle: 'mieter',
email: email.trim(),
einheitId: c.einheitId,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
let aktiv = true
;(async () => {
try {
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
if (!aktiv) return
setMe(benutzer)
await ladeAlles()
} catch {
/* nicht angemeldet – Login-Seite zeigen */
} finally {
if (aktiv) setBereit(true)
}
dispatch({ type: 'registriert', benutzer: b, code: c.code })
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
})()
return () => {
aktiv = false
}
}, [ladeAlles])
const benutzerAnlegen = async (
benutzername: string,
name: string,
rolle: BenutzerRolle,
email: string,
passwort: string,
einheitId: string | null,
) => {
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle,
email: email.trim(),
einheitId: rolle === 'mieter' ? einheitId : null,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
daten.units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
daten.units.find((u) => u.id === DEMO_UNIT_ID) ??
daten.units[0]
const fehlerNachricht = (e: unknown): string =>
e instanceof ApiFehler ? e.message : 'Ein unerwarteter Fehler ist aufgetreten.'
const value = useMemo<Store>(() => ({
...daten,
bereit,
me,
role,
meineEinheit,
// ── Auth ────────────────────────────────────────────────
async login(benutzername, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/login', {
benutzername,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
dispatch({ type: 'benutzerAngelegt', benutzer: b })
return null
}
const setPasswort = async (id: string, passwort: string) => {
dispatch({ type: 'passwortGesetzt', id, hash: await sha256(passwort) })
}
const generiereCodes = (anzahl: number, einheitId: string | null) => {
const neu: RegistrierungsCode[] = Array.from({ length: anzahl }, () => ({
code: neuerCode(),
einheitId,
erstelltAm: new Date().toLocaleDateString('de-DE'),
},
async logout() {
try {
await apiPost('/api/auth/logout')
} catch {
/* Session ggf. bereits ungültig – trotzdem lokal abmelden */
}
setMe(null)
setDaten(LEER)
},
async registrieren(code, benutzername, name, email, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
code,
benutzername,
name,
email,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Schadensmeldungen ───────────────────────────────────
async addTicket(t) {
try {
await apiPost('/api/tickets', t)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setTicketStatus(id, status) {
const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status })
setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) }))
},
// ── Nachrichten ─────────────────────────────────────────
async newThread(subject, text, unitId) {
try {
const { thread } = await apiPost<{ thread: Thread }>('/api/threads', { subject, text, unitId })
setDaten((d) => ({ ...d, threads: [thread, ...d.threads.filter((t) => t.id !== thread.id)] }))
return thread.id
} catch (e) {
console.error(fehlerNachricht(e))
return null
}
},
async addMessage(threadId, text) {
const { thread } = await apiPost<{ thread: Thread }>(`/api/threads/${threadId}/messages`, { text })
setDaten((d) => ({ ...d, threads: d.threads.map((t) => (t.id === threadId ? thread : t)) }))
},
markThreadRead(threadId) {
// Optimistisch lokal markieren, serverseitig nachmelden
setDaten((d) => ({
...d,
threads: d.threads.map((t) =>
t.id === threadId
? role === 'tenant'
? { ...t, unreadByTenant: false }
: { ...t, unreadByAdmin: false }
: t,
),
}))
dispatch({ type: 'codesHinzugefuegt', codes: neu })
return neu
}
return {
...state,
dispatch,
units,
announcements,
me,
role,
meineEinheit,
login,
logout,
registrieren,
benutzerAnlegen,
setAktiv: (id, aktiv) => dispatch({ type: 'benutzerAktiv', id, aktiv }),
setPasswort,
weiseEinheitZu: (id, einheitId) => dispatch({ type: 'einheitZugewiesen', id, einheitId }),
generiereCodes,
}
}, [state])
apiPost(`/api/threads/${threadId}/read`).catch(() => {})
},
// ── Benutzerverwaltung (Verwaltung) ─────────────────────
async benutzerAnlegen(benutzername, name, rolle, email, passwort, einheitId) {
try {
await apiPost('/api/users', {
benutzername,
name,
rolle,
email,
einheitId,
passwortHash: await sha256(passwort),
})
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setAktiv(id, aktiv) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async setPasswort(id, passwort) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, {
passwortHash: await sha256(passwort),
})
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async weiseEinheitZu(id, einheitId) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async generiereCodes(anzahl, einheitId) {
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
await ladeAlles()
return codes
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
}
@@ -395,4 +275,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
})
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }
export { DEMO_PASSWORT }