Files
Mieterportal/server/src/index.js
T
ah-admin 0bdf6e7b28 v1.4: HTML-E-Mail-Benachrichtigungen im Schulportal-Stil
- server/src/mail.js: baueHtmlMail/buttonHtml (600-px-Karte, Kopfzeile in
  Hinterstein-Farben #22333b + Amber, Zeilen-Tabelle, Status-Box, Zitat-Box,
  Aktions-Button 'Zum Mieterportal', Footer), immer mit Plain-Text-Fallback
- index.js: Ticket, neue Unterhaltung und Antwort versenden HTML + Text
  (multipart/alternative ueber die Mail-API)
- Vorschauen: preview/08-mail-ticket.png, preview/08-mail-nachricht.png
- E2E live verifiziert (Zustellung an axel@hocks.eu, status=sent), Doku
2026-10-02 13:09:50 +02:00

511 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Hinterstein Mieterportal – Node-API (Express + PostgreSQL).
*
* Authentifizierung: Session-Cookie mp_session (HttpOnly, 8 h), Passwörter als
* SHA-256-Hash (wie bisher im Browser berechnet, Vergleich serverseitig).
* Rollenrechte: Mieter sehen nur Daten ihrer Wohnung, Verwaltung alles.
* Benachrichtigungen (Ticket/Nachricht) werden serverseitig per Mail verschickt.
*/
const express = require('express')
const crypto = require('crypto')
const { pool, init, sessionsAufraeumen } = require('./db')
const { seed } = require('./seed')
const mail = require('./mail')
const PORT = Number(process.env.PORT || 3000)
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
const COOKIE_NAME = 'mp_session'
const app = express()
app.use(express.json({ limit: '256kb' }))
app.set('trust proxy', true)
// ── Cookie-Helfer (bewusst ohne Dependency) ──────────────────
function cookieLesen(req, name) {
const raw = req.headers.cookie || ''
for (const teil of raw.split(';')) {
const i = teil.indexOf('=')
if (i > -1 && teil.slice(0, i).trim() === name) return decodeURIComponent(teil.slice(i + 1).trim())
}
return null
}
function cookieSetzen(res, token, maxAgeMs, secure) {
res.append(
'Set-Cookie',
`${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; ` +
`Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? '; Secure' : ''}`,
)
}
// ── Auth-Middleware ──────────────────────────────────────────
async function auth(req, res, next) {
try {
const token = cookieLesen(req, COOKIE_NAME)
if (token) {
const { rows } = await pool.query(
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = $1 AND s.expires > now() AND u.aktiv`,
[token],
)
if (rows.length) {
req.user = rows[0]
req.sessionToken = token
return next()
}
}
res.status(401).json({ fehler: 'Nicht angemeldet.' })
} catch (e) {
next(e)
}
}
function adminOnly(req, res, next) {
if (req.user.rolle !== 'verwaltung') return res.status(403).json({ fehler: 'Nur für die Verwaltung.' })
next()
}
const oeffentlicherBenutzer = (b) => ({
id: b.id,
benutzername: b.benutzername,
name: b.name,
rolle: b.rolle,
email: b.email,
einheitId: b.einheit_id,
aktiv: b.aktiv,
erstelltAm: b.erstellt_am,
letzterLogin: b.letzter_login ?? undefined,
})
async function sessionAnlegen(res, userId, req) {
const token = crypto.randomBytes(32).toString('hex')
await pool.query(
'INSERT INTO sessions (token, user_id, expires) VALUES ($1, $2, now() + $3 * interval \'1 millisecond\')',
[token, userId, SESSION_DAUER_MS],
)
cookieSetzen(res, token, SESSION_DAUER_MS, req.secure)
}
// ── Mapping-Helfer (snake_case → camelCase) ──────────────────
const mapUnit = (u) => ({
id: u.id, building: u.building, number: u.number, floor: u.floor,
size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent),
tenant: u.tenant, status: u.status,
})
const mapTransaction = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year,
amount: Number(t.amount), type: t.type, status: t.status, paidAt: t.paid_at ?? undefined,
})
const mapTicket = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, title: t.title,
description: t.description, category: t.category, priority: t.priority,
status: t.status, createdAt: t.created_at,
})
const mapDocument = (d) => ({
id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date,
})
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
const mapThread = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
})
const mapCode = (c) => ({ code: c.code, einheitId: c.einheit_id, erstelltAm: c.erstellt_am, verwendetAm: c.verwendet_am ?? undefined })
const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: a.date, tag: a.tag })
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
// ── Health ───────────────────────────────────────────────────
app.get('/api/healthz', async (_req, res) => {
try {
await pool.query('SELECT 1')
res.json({ ok: true, dienst: 'mp-api', datenbank: true })
} catch {
res.status(503).json({ ok: false, datenbank: false })
}
})
// ── Auth ─────────────────────────────────────────────────────
app.post('/api/auth/login', async (req, res, next) => {
try {
const { benutzername, passwortHash } = req.body || {}
const { rows } = await pool.query(
'SELECT * FROM users WHERE lower(benutzername) = lower($1)',
[String(benutzername || '').trim()],
)
const b = rows[0]
if (!b || String(passwortHash || '') !== b.passwort_hash)
return res.status(401).json({ fehler: 'Benutzername oder Passwort falsch.' })
if (!b.aktiv)
return res.status(403).json({ fehler: 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.' })
await pool.query('UPDATE users SET letzter_login = $1 WHERE id = $2', [deutschesDatumZeit(), b.id])
await sessionAnlegen(res, b.id, req)
res.json({ benutzer: oeffentlicherBenutzer(b) })
} catch (e) { next(e) }
})
app.post('/api/auth/logout', async (req, res) => {
const token = cookieLesen(req, COOKIE_NAME)
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [token])
cookieSetzen(res, '', 0, req.secure)
res.json({ ok: true })
})
app.get('/api/auth/me', auth, (req, res) => {
res.json({ benutzer: oeffentlicherBenutzer(req.user) })
})
app.post('/api/auth/register', async (req, res, next) => {
try {
const { code, benutzername, name, email, passwortHash } = req.body || {}
const codeNorm = String(code || '').trim().toUpperCase()
const benutzernameNorm = String(benutzername || '').trim()
// Erst Benutzername prüfen, dann Code atomar verbrauchen (sonst wäre der
// Code bei einem vergebenen Benutzernamen trotzdem verbraucht)
const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm])
if (vorhanden.rows.length)
return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' })
const { rows: codeRows } = await pool.query(
`UPDATE registration_codes SET verwendet_am = $1
WHERE code = $2 AND verwendet_am IS NULL
RETURNING einheit_id`,
[deutschesDatum(), codeNorm],
)
if (!codeRows.length)
return res.status(400).json({ fehler: 'Der Registrierungscode ist ungültig oder bereits verwendet.' })
const id = `b-${Date.now()}`
const { rows } = await pool.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)
VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7) RETURNING *`,
[id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''),
String(email || '').trim(), codeRows[0].einheit_id, deutschesDatum()],
)
await sessionAnlegen(res, id, req)
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
} catch (e) { next(e) }
})
// ── Bootstrap (alle Stammdaten in einem Rutsch) ──────────────
app.get('/api/bootstrap', auth, async (req, res, next) => {
try {
const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
? pool.query('SELECT * FROM transactions ')
: pool.query('SELECT * FROM transactions WHERE unit_id = $1 ', [einheit]),
istAdmin
? pool.query('SELECT * FROM documents ORDER BY date DESC')
: pool.query('SELECT * FROM documents WHERE unit_id = $1 OR unit_id IS NULL ORDER BY date DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM tickets ORDER BY created_at DESC')
: pool.query('SELECT * FROM tickets WHERE unit_id = $1 ORDER BY created_at DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM threads ORDER BY id DESC')
: pool.query('SELECT * FROM threads WHERE unit_id = $1 ORDER BY id DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM messages')
: pool.query('SELECT m.* FROM messages m JOIN threads t ON t.id = m.thread_id WHERE t.unit_id = $1', [einheit]),
pool.query('SELECT * FROM announcements ORDER BY date DESC'),
istAdmin
? pool.query('SELECT * FROM users ORDER BY name')
: { rows: [] },
istAdmin
? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
: { rows: [] },
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
for (const m of messages.rows) {
;(nachrichtenProThread[m.thread_id] ??= []).push(mapMessage(m))
}
const threadListe = threads.rows.map((t) => ({
...mapThread(t),
messages: nachrichtenProThread[t.id] ?? [],
}))
res.json({
units: units.rows.map(mapUnit),
transactions: transactions.rows.map(mapTransaction),
documents: documents.rows.map(mapDocument),
tickets: tickets.rows.map(mapTicket),
threads: threadListe,
announcements: announcements.rows.map(mapAnnouncement),
benutzer: users.rows.map(oeffentlicherBenutzer),
codes: codes.rows.map(mapCode),
})
} catch (e) { next(e) }
})
// ── Tickets ──────────────────────────────────────────────────
app.post('/api/tickets', auth, async (req, res, next) => {
try {
const { title, description, category, priority } = req.body || {}
if (!String(title || '').trim()) return res.status(400).json({ fehler: 'Titel fehlt.' })
const unitId = req.user.rolle === 'verwaltung'
? String((req.body || {}).unitId || 'hs-1')
: req.user.einheit_id
const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
const id = `t-${Date.now()}`
const mieterName = req.user.rolle === 'verwaltung' ? 'Hinterstein Immobilien (intern)' : req.user.name
const { rows } = await pool.query(
`INSERT INTO tickets (id, unit_id, tenant, title, description, category, priority, status, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'offen',$8) RETURNING *`,
[id, unitId, mieterName, String(title).trim(), String(description || '–').trim(),
String(category || 'Sonstiges'), ['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel',
deutschesDatum()],
)
const label = mail.einheitLabel(mapUnit(unitRows[0]))
const betreff = `Neue Schadensmeldung: ${String(title).trim()} (${label})`
const text =
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
`Wohnung: ${label}\nMeldung von: ${mieterName}\nKategorie: ${String(category || 'Sonstiges')}\n\n` +
`${String(title).trim()}\n${String(description || '–').trim()}`
const html = mail.baueHtmlMail({
titel: 'Neue Schadensmeldung',
untertitel: label,
einleitung: 'Im Mieterportal wurde eine neue Schadensmeldung erstellt:',
zeilen: [
['Wohnung', label],
['Meldung von', mieterName],
['Kategorie', String(category || 'Sonstiges')],
],
statusBox: { hintergrund: '#f0fdfa', farbe: '#0f766e', text: 'Status: offen – neu eingegangen' },
zitat: `${String(title).trim()}\n${String(description || '–').trim()}`,
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/schadensmeldungen`),
})
mail.benachrichtigeVerwaltung(betreff, text, html)
res.json({ ticket: mapTicket(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
try {
const status = String((req.body || {}).status || '')
if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status))
return res.status(400).json({ fehler: 'Ungültiger Status.' })
const { rows } = await pool.query(
'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id],
)
if (!rows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' })
res.json({ ticket: mapTicket(rows[0]) })
} catch (e) { next(e) }
})
// ── Nachrichten (Threads) ────────────────────────────────────
app.post('/api/threads', auth, async (req, res, next) => {
try {
const { subject, text, unitId: gewuenschteEinheit } = req.body || {}
if (!String(subject || '').trim() || !String(text || '').trim())
return res.status(400).json({ fehler: 'Betreff und Nachricht fehlen.' })
const unitId = req.user.rolle === 'verwaltung'
? String(gewuenschteEinheit || 'hs-1')
: req.user.einheit_id
const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
const id = `m-${Date.now()}`
const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant'
const autor = req.user.name
await pool.query(
`INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant)
VALUES ($1,$2,$3,$4,$5,$6)`,
[id, unitId, req.user.rolle === 'verwaltung' ? autor : (unitRows[0].tenant ?? autor),
String(subject).trim(), vonRolle === 'tenant', vonRolle === 'admin'],
)
await pool.query(
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
[`${id}-1`, id, vonRolle, autor, String(text).trim(), deutschesDatumZeit()],
)
const betreff = vonRolle === 'tenant'
? `Neue Nachricht im Mieterportal: ${String(subject).trim()}`
: `Nachricht der Verwaltung: ${String(subject).trim()}`
const textVersion = `Neue Unterhaltung von ${autor}:\n\n„${String(text).trim()}“`
const html = mail.baueHtmlMail({
titel: vonRolle === 'tenant' ? 'Neue Nachricht' : 'Nachricht der Verwaltung',
untertitel: String(subject).trim(),
einleitung: `Neue Unterhaltung von ${autor}:`,
zeilen: [
['Wohnung', mail.einheitLabel(mapUnit(unitRows[0]))],
['Betreff', String(subject).trim()],
],
zitat: String(text).trim(),
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`),
})
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, textVersion, html)
else mail.benachrichtigeMieter(unitId, betreff, textVersion, html)
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [id])
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [id])
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
} catch (e) { next(e) }
})
app.post('/api/threads/:id/messages', auth, async (req, res, next) => {
try {
const text = String((req.body || {}).text || '').trim()
if (!text) return res.status(400).json({ fehler: 'Nachricht fehlt.' })
const { rows: threadRows } = await pool.query('SELECT * FROM threads WHERE id = $1', [req.params.id])
const th = threadRows[0]
if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' })
if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id)
return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' })
const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant'
const autor = req.user.name
const msgId = `${th.id}-${Date.now()}`
await pool.query(
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
[msgId, th.id, vonRolle, autor, text, deutschesDatumZeit()],
)
await pool.query(
`UPDATE threads SET unread_by_admin = $1, unread_by_tenant = $2 WHERE id = $3`,
[vonRolle === 'tenant', vonRolle === 'admin', th.id],
)
const betreff = vonRolle === 'tenant'
? `Neue Antwort im Mieterportal: ${th.subject}`
: `Antwort der Verwaltung: ${th.subject}`
const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [th.unit_id])
const textVersion = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${text}“`
const html = mail.baueHtmlMail({
titel: vonRolle === 'tenant' ? 'Neue Antwort' : 'Antwort der Verwaltung',
untertitel: th.subject,
einleitung: `Neue Nachricht von ${autor}:`,
zeilen: [
['Wohnung', mail.einheitLabel(mapUnit(einheitRows[0]))],
['Unterhaltung', th.subject],
],
zitat: text,
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`),
})
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, textVersion, html)
else mail.benachrichtigeMieter(th.unit_id, betreff, textVersion, html)
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [th.id])
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [th.id])
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
} catch (e) { next(e) }
})
app.post('/api/threads/:id/read', auth, async (req, res, next) => {
try {
const { rows: threadRows } = await pool.query('SELECT unit_id FROM threads WHERE id = $1', [req.params.id])
const th = threadRows[0]
if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' })
if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id)
return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' })
if (req.user.rolle === 'verwaltung')
await pool.query('UPDATE threads SET unread_by_admin = FALSE WHERE id = $1', [req.params.id])
else
await pool.query('UPDATE threads SET unread_by_tenant = FALSE WHERE id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Benutzerverwaltung (nur Verwaltung) ──────────────────────
app.get('/api/users', auth, adminOnly, async (_req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM users ORDER BY name')
res.json({ benutzer: rows.map(oeffentlicherBenutzer) })
} catch (e) { next(e) }
})
app.post('/api/users', auth, adminOnly, async (req, res, next) => {
try {
const { benutzername, name, rolle, email, passwortHash, einheitId } = req.body || {}
if (!['verwaltung', 'mieter'].includes(rolle)) return res.status(400).json({ fehler: 'Ungültige Rolle.' })
const benutzernameNorm = String(benutzername || '').trim()
const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm])
if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' })
const id = `b-${Date.now()}`
const { rows } = await pool.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,TRUE,$8) RETURNING *`,
[id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''), rolle,
String(email || '').trim(), rolle === 'mieter' ? (einheitId || null) : null, deutschesDatum()],
)
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => {
try {
const b = req.body || {}
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Benutzer nicht gefunden.' })
if (b.passwortHash !== undefined)
await pool.query('UPDATE users SET passwort_hash = $1 WHERE id = $2', [String(b.passwortHash), req.params.id])
if (b.aktiv !== undefined)
await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id])
if (b.einheitId !== undefined)
await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id])
const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
res.json({ benutzer: oeffentlicherBenutzer(neu[0]) })
} catch (e) { next(e) }
})
// ── Registrierungscodes (nur Verwaltung) ─────────────────────
app.get('/api/codes', auth, adminOnly, async (_req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
res.json({ codes: rows.map(mapCode) })
} catch (e) { next(e) }
})
app.post('/api/codes', auth, adminOnly, async (req, res, next) => {
try {
const anzahl = Math.min(Math.max(parseInt((req.body || {}).anzahl, 10) || 1, 1), 50)
const einheitId = (req.body || {}).einheitId || null
if (einheitId) {
const u = await pool.query('SELECT 1 FROM units WHERE id = $1', [einheitId])
if (!u.rows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
}
const teil = () =>
Array.from(crypto.randomBytes(2)).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase()
const neu = []
for (let i = 0; i < anzahl; i++) {
const code = `MP-${teil()}-${teil()}`
await pool.query(
'INSERT INTO registration_codes (code, einheit_id, erstellt_am) VALUES ($1,$2,$3)',
[code, einheitId, deutschesDatum()],
)
neu.push({ code, einheitId, erstelltAm: deutschesDatum() })
}
res.json({ codes: neu })
} catch (e) { next(e) }
})
// ── Fehlerbehandlung ─────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error('[api]', err)
res.status(500).json({ fehler: 'Interner Serverfehler.' })
})
// ── Start ────────────────────────────────────────────────────
async function warteAufDatenbank() {
let letzterFehler = null
for (let versuch = 1; versuch <= 30; versuch++) {
try {
await init()
return
} catch (e) {
letzterFehler = e
console.log(`[api] Datenbank nicht bereit (Versuch ${versuch}/30) …`)
await new Promise((r) => setTimeout(r, 2000))
}
}
throw letzterFehler
}
async function start() {
// Warten, bis die Datenbank erreichbar ist (Container-Start-Reihenfolge)
await warteAufDatenbank()
const geseedet = await seed()
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
}
start().catch((e) => {
console.error('[api] Start fehlgeschlagen:', e)
process.exit(1)
})