/** 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, useState, type ReactNode, } from 'react' import type { Role, Ticket, TicketStatus, Transaction, DocumentItem, Thread, Benutzer, BenutzerRolle, RegistrierungsCode, Unit, Announcement, } from '../types' import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' /** 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' interface Bootstrap { units: Unit[] transactions: Transaction[] documents: DocumentItem[] tickets: Ticket[] threads: Thread[] announcements: Announcement[] benutzer: Benutzer[] codes: RegistrierungsCode[] } const LEER: Bootstrap = { units: [], transactions: [], documents: [], tickets: [], threads: [], announcements: [], benutzer: [], codes: [], } interface Store extends Bootstrap { /** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */ bereit: boolean me: Benutzer | null role: Role meineEinheit: Unit login: (benutzername: string, passwort: string) => Promise logout: () => Promise registrieren: ( code: string, benutzername: string, name: string, email: string, passwort: string, ) => Promise addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise setTicketStatus: (id: string, status: TicketStatus) => Promise newThread: (subject: string, text: string, unitId?: string) => Promise addMessage: (threadId: string, text: string) => Promise markThreadRead: (threadId: string) => void benutzerAnlegen: ( benutzername: string, name: string, rolle: BenutzerRolle, email: string, passwort: string, einheitId: string | null, ) => Promise setAktiv: (id: string, aktiv: boolean) => Promise setPasswort: (id: string, passwort: string) => Promise weiseEinheitZu: (id: string, einheitId: string | null) => Promise generiereCodes: (anzahl: number, einheitId: string | null) => Promise } const StoreContext = createContext(null) export function StoreProvider({ children }: { children: ReactNode }) { const [bereit, setBereit] = useState(false) const [me, setMe] = useState(null) const [daten, setDaten] = useState(LEER) /** Lädt alle Stammdaten neu (nach Login/Registrierung und nach Schreibaktionen). */ const ladeAlles = useCallback(async () => { const b = await apiGet('/api/bootstrap') setDaten(b) }, []) // Start: bestehende Session prüfen, dann Daten laden useEffect(() => { let aktiv = true ;(async () => { try { const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me') await ladeAlles() // erst Daten, dann me – sonst rendern Seiten gegen leere Daten if (!aktiv) return setMe(benutzer) } catch { /* nicht angemeldet – Login-Seite zeigen */ } finally { if (aktiv) setBereit(true) } })() return () => { aktiv = false } }, [ladeAlles]) 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(() => ({ ...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), }) await ladeAlles() // erst Daten, dann me (s. useEffect) setMe(benutzer) return null } catch (e) { return fehlerNachricht(e) } }, 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), }) await ladeAlles() // erst Daten, dann me (s. useEffect) setMe(benutzer) 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 {children} } export function useStore(): Store { const ctx = useContext(StoreContext) if (!ctx) throw new Error('useStore außerhalb von StoreProvider') return ctx } export const fmtEUR = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', }) export { DEMO_PASSWORT }