/** 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) })