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
+4 -1
View File
@@ -215,7 +215,10 @@ export default function BenutzerVerwaltung() {
))}
</select>
<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"
>
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
+13 -86
View File
@@ -13,71 +13,21 @@ import {
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
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 }) {
const { dispatch, role, me, meineEinheit, benutzer } = useStore()
const { newThread, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState('')
const [text, setText] = useState('')
const verwaltungsName = 'Joseph Hinterstein'
const submit = () => {
const submit = async () => {
if (!subject.trim() || !text.trim()) return
const id = `m-${Date.now()}`
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName
dispatch({
type: 'newThread',
thread: {
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()}“`,
})
const id = await newThread(
subject.trim(),
text.trim(),
role === 'tenant' ? meineEinheit.id : 'hs-1',
)
if (!id) return
setSubject('')
setText('')
setOpen(false)
@@ -117,9 +67,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
}
export default function Messages() {
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore()
const meinName = me?.name ?? 'Mieter'
const verwaltungsName = 'Joseph Hinterstein'
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
const visible = role === 'tenant'
? threads.filter((t) => t.unitId === meineEinheit.id)
: threads
@@ -130,35 +78,14 @@ export default function Messages() {
useEffect(() => {
if (!active) return
dispatch({ type: 'markThreadRead', threadId: active.id, role })
markThreadRead(active.id)
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
const text = draft.trim()
const autor = role === 'tenant' ? meinName : verwaltungsName
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}“`,
})
await addMessage(active.id, text)
setDraft('')
}
+16 -43
View File
@@ -13,12 +13,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
nachrichtenText,
einheitLabel,
} from '../lib/mail'
import {
Select,
SelectContent,
@@ -52,45 +46,26 @@ const categories = [
]
function NewTicketDialog() {
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore()
const { addTicket, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('')
const [sende, setSende] = useState(false)
const submit = () => {
if (!title.trim()) return
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
dispatch({
type: 'addTicket',
ticket: {
id: `t-${Date.now()}`,
unitId,
tenant: mieterName,
title: title.trim(),
description: description.trim() || '–',
category,
priority,
status: 'offen',
createdAt: new Date().toLocaleDateString('de-DE'),
},
const submit = async () => {
if (!title.trim() || sende) return
setSende(true)
const err = await addTicket({
title: title.trim(),
description: description.trim() || '–',
category,
priority,
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
})
// Verwaltung per E-Mail benachrichtigen (fire-and-forget)
sendeBenachrichtigung(
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() || '–'}`,
),
)
setSende(false)
if (err) return
setTitle('')
setDescription('')
setOpen(false)
@@ -158,7 +133,7 @@ function NewTicketDialog() {
</div>
<DialogFooter>
<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>
</DialogContent>
</Dialog>
@@ -166,7 +141,7 @@ function NewTicketDialog() {
}
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 visible = useMemo(
@@ -247,9 +222,7 @@ export default function Tickets() {
{role === 'admin' ? (
<Select
value={t.status}
onValueChange={(v) =>
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
}
onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
>
<SelectTrigger className="h-8 w-40">
<SelectValue />