/** 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 fs = require('fs') const path = require('path') const multer = require('multer') const { pool, init, sessionsAufraeumen } = require('./db') const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const mail = require('./mail') const { erzeugeAbrechnung } = require('./abrechnung') const zaehlerVerlauf = require('./zaehlerVerlauf') const PORT = Number(process.env.PORT || 3000) const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 const COOKIE_NAME = 'mp_session' // KI-Antworten auf Schadensmeldungen über n8n (leer = deaktiviert) const N8N_TICKET_WEBHOOK = process.env.N8N_TICKET_WEBHOOK || '' const KI_API_TOKEN = process.env.KI_API_TOKEN || '' // Beleg-Import aus SparkReceipt (leer = Webhook-Endpunkt deaktiviert) const SPARKRECEIPT_WEBHOOK_TOKEN = process.env.SPARKRECEIPT_WEBHOOK_TOKEN || '' // OCR-Dienst für die Beleg-Analyse (interner Container mp-ocr) const OCR_URL = process.env.OCR_URL || 'http://mp-ocr:8081' // PDF-Ablage für das Dokumentenmodul (im Container per Volume persistiert) const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads') const UPLOAD_MAX_BYTES = 25 * 1024 * 1024 // 25 MB pro Datei fs.mkdirSync(UPLOAD_DIR, { recursive: true }) const app = express() app.use(express.json({ limit: '256kb' })) app.set('trust proxy', true) // PDF-Uploads (nur application/pdf, 25 MB); der Dateiname wird erst nach dem // Dokument-Lookup vergeben → die Datei heißt wie die Dokument-ID (.pdf). const upload = multer({ storage: multer.diskStorage({ destination: UPLOAD_DIR, filename: (req, _file, cb) => cb(null, `${req.params.id}.pdf`), }), limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 }, fileFilter: (_req, file, cb) => { const istPdf = file.mimetype === 'application/pdf' || /\.pdf$/i.test(file.originalname || '') cb(istPdf ? null : new Error('Nur PDF-Dateien sind erlaubt.'), istPdf) }, }) /** Deutsche Größenangabe, z. B. 1,8 MB. */ function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) return '–' if (bytes < 1024) return `${bytes} B` const einheiten = ['KB', 'MB', 'GB'] let wert = bytes / 1024 let i = 0 while (wert >= 1024 && i < einheiten.length - 1) { wert /= 1024; i += 1 } return `${wert.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${einheiten[i]}` } // Prozess nie durch einen einzelnen Fehler killen lassen (z. B. defekter // JSON-Body von außen); Fehler werden geloggt, der Dienst läuft weiter. process.on('uncaughtException', (e) => console.error('[api] uncaughtException:', e)) process.on('unhandledRejection', (e) => console.error('[api] unhandledRejection:', e)) /** Feuert die KI-Antwort-Kette an n8n (fire-and-forget; die fertige Antwort * kommt asynchron über POST /api/ki-antwort zurück ins Portal). */ async function kiBenachrichtigen(daten) { if (!N8N_TICKET_WEBHOOK) return try { await fetch(N8N_TICKET_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(daten), signal: AbortSignal.timeout(5000), }) } catch (e) { console.warn('[ki] Webhook-Aufruf fehlgeschlagen:', e.message) } } // ── 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, telefon: b.telefon ?? null, kontoinhaber: b.kontoinhaber ?? null, iban: b.iban ?? null, bankname: b.bankname ?? null, }) 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, fileName: d.file_name ?? null, hasFile: Boolean(d.file_path), }) 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, ticketId: t.ticket_id ?? null, }) 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 mapMeter = (m) => ({ id: m.id, unitId: m.unit_id, building: m.building, nummer: m.nummer, art: m.art, stand: Number(m.stand), standAm: m.stand_am, geaendertAm: m.geaendert_am, letzteAnomalie: m.letzte_anomalie === undefined ? undefined : Boolean(m.letzte_anomalie), }) const mapBuilding = (b) => ({ name: b.name, adresse: b.adresse, strasse: b.strasse ?? '', hausnummer: b.hausnummer ?? '', plz: b.plz ?? '', ort: b.ort ?? '', lage: b.lage ?? '', baujahr: b.baujahr, stockwerke: b.stockwerke, etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten, // Fallback: Bestand vor v1.26 trägt die Wohnfläche noch in quadratmeter wohnflaeche: Number(b.wohnflaeche ?? b.quadratmeter), grundstuecksflaeche: b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined ? null : Number(b.grundstuecksflaeche), }) const mapKosten = (k) => ({ id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr, kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar, notiz: k.notiz, erfasstAm: k.erfasst_am, }) const mapBeleg = (b) => ({ id: b.id, quelle: b.quelle, externeId: b.externe_id, haendler: b.haendler, betrag: b.betrag === null ? null : Number(b.betrag), waehrung: b.waehrung, datum: b.datum, jahr: b.jahr, kategorie: b.kategorie, notiz: b.notiz, dateiUrl: b.datei_url, zugeordnet: b.zugeordnet, erfasstAm: b.erfasst_am, unitId: b.unit_id ?? null, gebaeude: b.gebaude ?? null, zuordnung: b.zuordnung ?? null, }) 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) }) }) // Stammdaten-Selbstpflege (v1.20): Telefon, E-Mail, Bankverbindung app.patch('/api/auth/profil', auth, async (req, res, next) => { try { const { email, telefon, kontoinhaber, iban, bankname } = req.body || {} const emailNeu = String(email || '').trim() if (!/^\S+@\S+\.\S+$/.test(emailNeu)) return res.status(400).json({ fehler: 'Gültige E-Mail-Adresse erforderlich.' }) const { rows: fremde } = await pool.query( 'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [emailNeu, req.user.id], ) if (fremde.length) return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' }) const ibanNeu = String(iban || '').trim().replace(/\s+/g, '').toUpperCase() if (ibanNeu && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(ibanNeu)) return res.status(400).json({ fehler: 'Ungültige IBAN.' }) const { rows } = await pool.query( `UPDATE users SET email = $1, telefon = $2, kontoinhaber = $3, iban = $4, bankname = $5 WHERE id = $6 RETURNING *`, [emailNeu, String(telefon || '').trim() || null, String(kontoinhaber || '').trim() || null, ibanNeu || null, String(bankname || '').trim() || null, req.user.id], ) res.json({ benutzer: oeffentlicherBenutzer(rows[0]) }) } catch (e) { next(e) } }) 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) // Verwaltung über die neue Registrierung benachrichtigen (fire-and-forget) const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [codeRows[0].einheit_id]) const label = mail.einheitLabel(mapUnit(einheitRows[0])) const benutzernameNeu = String(benutzername || '').trim() const nameNeu = String(name || '').trim() const emailNeu = String(email || '').trim() const betreff = `Neue Mieter-Registrierung: ${nameNeu} (${label})` const text = `Im Mieterportal hat sich ein neuer Mieter registriert.\n\n` + `Name: ${nameNeu}\nBenutzername: ${benutzernameNeu}\nE-Mail: ${emailNeu}\nWohnung: ${label}\nRegistriert am: ${deutschesDatumZeit()}` const html = mail.baueHtmlMail({ titel: 'Neue Mieter-Registrierung', untertitel: label, einleitung: `Im Mieterportal hat sich ein neuer Mieter registriert:`, zeilen: [ ['Name', nameNeu], ['Benutzername', benutzernameNeu], ['E-Mail', emailNeu], ['Wohnung', label], ['Registriert am', deutschesDatumZeit()], ], aktionenHtml: mail.buttonHtml('Zur Benutzerverwaltung', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/benutzer`), }) mail.benachrichtigeVerwaltung(betreff, text, html) 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, meters, buildings, costs, belege] = 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: [] }, istAdmin ? pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters ORDER BY building, nummer`) : pool.query(`SELECT *, (SELECT anomalie FROM meter_readings r WHERE r.meter_id = meters.id ORDER BY r.seq DESC LIMIT 1) AS letzte_anomalie FROM meters WHERE unit_id = $1 ORDER BY nummer`, [einheit]), istAdmin ? pool.query('SELECT * FROM buildings ORDER BY name') : { rows: [] }, istAdmin ? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart') : pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]), istAdmin ? pool.query('SELECT * FROM belege ORDER BY erfasst_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), meters: meters.rows.map(mapMeter), buildings: buildings.rows.map(mapBuilding), kosten: costs.rows.map(mapKosten), belege: belege.rows.map(mapBeleg), }) } 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()], ) // ── Automatische Unterhaltung (Kommunikation) zur Meldung anlegen ── // Jede Schadensmeldung startet einen eigenen Thread; Antworten darauf // lösen über /api/threads/:id/messages bereits Mails an beide Seiten aus. const threadId = `m-${Date.now()}` const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant' const threadBetreff = `Schadensmeldung: ${String(title).trim()}` const ersteNachricht = `Schadensmeldung eingegangen – Kategorie: ${String(category || 'Sonstiges')}, ` + `Priorität: ${['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel'}.\n\n` + `${String(title).trim()}\n${String(description || '–').trim()}` await pool.query( `INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant, ticket_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [threadId, unitId, mieterName, threadBetreff, vonRolle === 'tenant', vonRolle === 'admin', id], ) await pool.query( `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`, [`${threadId}-1`, threadId, vonRolle, mieterName, ersteNachricht, deutschesDatumZeit()], ) 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) // KI-Antwort-Kette anstoßen (nur bei Mieter-Meldungen, nicht bei internen) if (vonRolle === 'tenant') { kiBenachrichtigen({ ticketId: id, threadId, subject: threadBetreff, title: String(title).trim(), description: String(description || '–').trim(), category: String(category || 'Sonstiges'), priority: ['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel', tenant: mieterName, unit: label, }) } // Eingangsbestätigung an den Mieter (bei internen Meldungen der // Verwaltung entfällt sie, da diese keine Mieter-E-Mail auslösen soll) if (vonRolle === 'tenant') { const bestText = `Guten Tag,\n\nvielen Dank für Ihre Schadensmeldung „${String(title).trim()}“. ` + `Diese ist bei uns eingegangen und wird von der Verwaltung bearbeitet.\n\n` + `Wohnung: ${label}\nKategorie: ${String(category || 'Sonstiges')}\n\n` + `Sie können den Fortschritt im Mieterportal verfolgen und sich jederzeit ` + `in der zugehörigen Unterhaltung („${threadBetreff}“) an uns wenden.` const bestHtml = mail.baueHtmlMail({ titel: 'Schadensmeldung eingegangen', untertitel: label, einleitung: `Vielen Dank für Ihre Schadensmeldung „${String(title).trim()}“. Diese ist bei uns eingegangen und wird von der Verwaltung bearbeitet.`, zeilen: [ ['Wohnung', label], ['Kategorie', String(category || 'Sonstiges')], ], statusBox: { hintergrund: '#f0fdfa', farbe: '#0f766e', text: 'Status: offen – eingegangen' }, zitat: `${String(title).trim()}\n${String(description || '–').trim()}`, aktionenHtml: mail.buttonHtml('Meldung im Portal ansehen', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), }) mail.benachrichtigeMieter(unitId, `Eingangsbestätigung: ${String(title).trim()}`, bestText, bestHtml) } 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: altRows } = await pool.query('SELECT status FROM tickets WHERE id = $1', [req.params.id]) if (!altRows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' }) const alterStatus = altRows[0].status const { rows } = await pool.query( 'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id], ) // Statuswechsel als Systemnachricht in die verknüpfte Unterhaltung schreiben, // damit der gesamte Verlauf der Schadensmeldung an einem Ort bleibt (v1.19). const { rows: threadRows } = await pool.query( 'SELECT * FROM threads WHERE ticket_id = $1', [req.params.id], ) if (threadRows.length && alterStatus !== status) { const th = threadRows[0] const systemText = `Status der Schadensmeldung geändert: ${alterStatus} → ${status}` await pool.query( `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,'admin',$3,$4,$5)`, [`${th.id}-${Date.now()}`, th.id, req.user.name, systemText, deutschesDatumZeit()], ) await pool.query('UPDATE threads SET unread_by_tenant = TRUE WHERE id = $1', [th.id]) const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [th.unit_id]) const label = mail.einheitLabel(mapUnit(einheitRows[0])) const textVersion = `Der Status der Schadensmeldung „${rows[0].title}“ wurde geändert:\n\n` + `${alterStatus} → ${status}\n\nWohnung: ${label}` const html = mail.baueHtmlMail({ titel: 'Status aktualisiert', untertitel: th.subject, einleitung: `Der Status Ihrer Schadensmeldung „${rows[0].title}“ wurde von der Verwaltung geändert.`, zeilen: [ ['Wohnung', label], ['Vorher', alterStatus], ['Neu', status], ], statusBox: { hintergrund: '#f0fdfa', farbe: '#0f766e', text: `Status: ${status}` }, aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), }) mail.benachrichtigeMieter(th.unit_id, `Status: ${status} – ${rows[0].title}`, textVersion, html) } res.json({ ticket: mapTicket(rows[0]) }) } catch (e) { next(e) } }) // ── Zählerstände (Strom / Wasser / Gas) ───────────────────── const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] app.post('/api/meters', auth, adminOnly, async (req, res, next) => { try { const { nummer, art, stand, unitId, building, standAm } = req.body || {} if (!String(nummer || '').trim()) return res.status(400).json({ fehler: 'Zählernummer fehlt.' }) if (!ZAEHLER_ARTEN.includes(art)) return res.status(400).json({ fehler: 'Ungültige Zählerart.' }) const standZahl = Number(stand) if (!Number.isFinite(standZahl) || standZahl < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) const datum = String(standAm || '').trim() || deutschesDatum() if (!zaehlerVerlauf.parseDatum(datum)) return res.status(400).json({ fehler: 'Ungültiges Ablesedatum.' }) // Wohnung angegeben → Haus aus der Wohnung ableiten; sonst muss das Haus im Body stehen let einheitId = unitId ? String(unitId) : null let haus = String(building || '').trim() if (einheitId) { const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [einheitId]) if (!u.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) haus = u[0].building } else if (!haus) { return res.status(400).json({ fehler: 'Haus fehlt (bei Zählern ohne Wohnung).' }) } const id = `z-${Date.now()}` const { rows } = await pool.query( `INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [id, einheitId, haus, String(nummer).trim(), art, standZahl, datum, deutschesDatumZeit()], ) // Anfangsstand als erster Verlaufseintrag await zaehlerVerlauf.standEintragen({ meter: rows[0], stand: standZahl, standAm: datum, erfasstVon: req.user.name, quelle: 'erstbefund', }) res.json({ meter: mapMeter(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => { try { const b = req.body || {} const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Zähler nicht gefunden.' }) const m = rows[0] const neu = { nummer: b.nummer !== undefined ? String(b.nummer).trim() : m.nummer, art: b.art !== undefined ? String(b.art) : m.art, stand: b.stand !== undefined ? Number(b.stand) : Number(m.stand), unit_id: b.unitId !== undefined ? (b.unitId || null) : m.unit_id, building: m.building, } if (!neu.nummer) return res.status(400).json({ fehler: 'Zählernummer darf nicht leer sein.' }) if (!ZAEHLER_ARTEN.includes(neu.art)) return res.status(400).json({ fehler: 'Ungültige Zählerart.' }) if (!Number.isFinite(neu.stand) || neu.stand < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) if (neu.unit_id) { const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [neu.unit_id]) if (!u.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) neu.building = u[0].building // Haus folgt immer der Wohnung } else if (b.building !== undefined && String(b.building).trim()) { neu.building = String(b.building).trim() } // Stand-Datum nur neu setzen, wenn sich der Stand ändert const standGeaendert = b.stand !== undefined && neu.stand !== Number(m.stand) const { rows: aktualisiert } = await pool.query( `UPDATE meters SET nummer = $1, art = $2, stand = $3, unit_id = $4, building = $5, stand_am = $6, geaendert_am = $7 WHERE id = $8 RETURNING *`, [neu.nummer, neu.art, neu.stand, neu.unit_id, neu.building, standGeaendert ? deutschesDatum() : m.stand_am, deutschesDatumZeit(), req.params.id], ) // Geänderter Stand → neuer Verlaufseintrag (inkl. Anomalieprüfung + Mail) if (standGeaendert) { await zaehlerVerlauf.standEintragen({ meter: aktualisiert[0], stand: neu.stand, standAm: deutschesDatum(), erfasstVon: req.user.name, quelle: 'manuel', }) } res.json({ meter: mapMeter(aktualisiert[0]) }) } catch (e) { next(e) } }) // ── Zählerverlauf (v1.25): Liste pro Zähler + Stand eintragen ── async function meterFuer(req, res) { const { rows } = await pool.query('SELECT * FROM meters WHERE id = $1', [req.params.id]) if (!rows.length) { res.status(404).json({ fehler: 'Zähler nicht gefunden.' }); return null } const m = rows[0] // Mieter dürfen nur die Zähler ihrer eigenen Wohneinheit sehen/pflegen if (req.user.rolle !== 'verwaltung' && m.unit_id !== req.user.einheit_id) { res.status(403).json({ fehler: 'Kein Zugriff auf diesen Zähler.' }) return null } return m } app.get('/api/meters/:id/readings', auth, async (req, res, next) => { try { const m = await meterFuer(req, res) if (!m) return const verlauf = await zaehlerVerlauf.verlaufLaden(m.id) res.json({ readings: verlauf.map(zaehlerVerlauf.mapReading) }) } catch (e) { next(e) } }) app.post('/api/meters/:id/readings', auth, async (req, res, next) => { try { const m = await meterFuer(req, res) if (!m) return const { stand, standAm } = req.body || {} const standZahl = Number(stand) if (!Number.isFinite(standZahl) || standZahl < 0) return res.status(400).json({ fehler: 'Ungültiger Zählerstand.' }) const datum = String(standAm || '').trim() || deutschesDatum() if (!zaehlerVerlauf.parseDatum(datum)) return res.status(400).json({ fehler: 'Ungültiges Ablesedatum.' }) const { eintrag, anomalie, grund } = await zaehlerVerlauf.standEintragen({ meter: m, stand: standZahl, standAm: datum, erfasstVon: req.user.name, quelle: req.user.rolle === 'verwaltung' ? 'manuel' : 'mieter', }) // meters.stand bleibt der aktuellste Wert (Cache für die Übersicht) const { rows: aktualisiert } = await pool.query( `UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4 RETURNING *`, [standZahl, datum, deutschesDatumZeit(), m.id], ) res.json({ eintrag, anomalie, grund, meter: mapMeter(aktualisiert[0]) }) } catch (e) { next(e) } }) // Einzelnen Verlaufseintrag löschen (Korrektur falscher Erfassungen, Verwaltung) app.delete('/api/meters/:id/readings/:rid', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query( 'DELETE FROM meter_readings WHERE id = $1 AND meter_id = $2 RETURNING *', [req.params.rid, req.params.id], ) if (!rows.length) return res.status(404).json({ fehler: 'Eintrag nicht gefunden.' }) // meters-Cache aus dem neuesten verbleibenden Eintrag neu aufbauen const { rows: letzter } = await pool.query( 'SELECT stand, stand_am FROM meter_readings WHERE meter_id = $1 ORDER BY seq DESC LIMIT 1', [req.params.id], ) if (letzter.length) { await pool.query( 'UPDATE meters SET stand = $1, stand_am = $2, geaendert_am = $3 WHERE id = $4', [letzter[0].stand, letzter[0].stand_am, deutschesDatumZeit(), req.params.id], ) } res.json({ ok: true }) } catch (e) { next(e) } }) // ── Dokumente (Verwaltung) ─────────────────────────────────── const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges'] async function dokumentGueltig(b, res) { const titel = String(b.title || '').trim() const kategorie = String(b.category || '').trim() if (!titel) { res.status(400).json({ fehler: 'Titel fehlt.' }); return null } if (!DOKUMENT_KATEGORIEN.includes(kategorie)) { res.status(400).json({ fehler: 'Ungültige Kategorie.' }); return null } let einheitId = b.unitId !== undefined ? (b.unitId || null) : undefined if (einheitId) { const { rows: u } = await pool.query('SELECT id FROM units WHERE id = $1', [einheitId]) if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null } } return { titel, kategorie, einheitId } } app.post('/api/documents', auth, adminOnly, async (req, res, next) => { try { const g = await dokumentGueltig(req.body || {}, res) if (!g) return const { rows } = await pool.query( `INSERT INTO documents (id, unit_id, title, category, size, date) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [`d-${Date.now()}`, g.einheitId ?? null, g.titel, g.kategorie, String(req.body.size || '').trim() || '–', String(req.body.date || '').trim() || deutschesDatum()], ) res.json({ dokument: mapDocument(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/documents/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) const d = rows[0] const b = req.body || {} const kandidat = { title: b.title !== undefined ? b.title : d.title, category: b.category !== undefined ? b.category : d.category, unitId: b.unitId !== undefined ? b.unitId : d.unit_id, } const g = await dokumentGueltig(kandidat, res) if (!g) return const einheitId = g.einheitId === undefined ? d.unit_id : g.einheitId const { rows: aktualisiert } = await pool.query( `UPDATE documents SET unit_id = $1, title = $2, category = $3, size = $4, date = $5 WHERE id = $6 RETURNING *`, [einheitId, g.titel, g.kategorie, b.size !== undefined ? (String(b.size).trim() || '–') : d.size, b.date !== undefined ? (String(b.date).trim() || d.date) : d.date, req.params.id], ) res.json({ dokument: mapDocument(aktualisiert[0]) }) } catch (e) { next(e) } }) app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('DELETE FROM documents WHERE id = $1 RETURNING file_path', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) if (rows[0].file_path) { // Datei best-effort mitlöschen (Fehler hier dürfen den Request nicht killen) await fs.promises.unlink(path.join(UPLOAD_DIR, rows[0].file_path)).catch(() => {}) } res.json({ ok: true }) } catch (e) { next(e) } }) // PDF-Upload zum Dokument (ersetzt eine vorhandene Datei; nur Verwaltung) app.post('/api/documents/:id/upload', auth, adminOnly, async (req, res, next) => { const { rows } = await pool.query('SELECT id, file_path FROM documents WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) const alteDatei = rows[0].file_path upload.single('file')(req, res, async (fehler) => { try { if (fehler) { const meldung = fehler.code === 'LIMIT_FILE_SIZE' ? 'Die Datei ist größer als 25 MB.' : (fehler.message || 'Der Upload ist fehlgeschlagen.') return res.status(fehler.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ fehler: meldung }) } if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' }) if (alteDatei && alteDatei !== req.file.filename) { await fs.promises.unlink(path.join(UPLOAD_DIR, alteDatei)).catch(() => {}) } const originalname = Buffer.from(req.file.originalname, 'latin1').toString('utf8') // MSIE-/Chrome-Kodierung geraderücken const { rows: aktualisiert } = await pool.query( 'UPDATE documents SET file_name = $1, file_path = $2, size = $3 WHERE id = $4 RETURNING *', [originalname, req.file.filename, formatBytes(req.file.size), req.params.id], ) res.json({ dokument: mapDocument(aktualisiert[0]) }) } catch (e) { next(e) } }) }) // PDF-Download: Verwaltung immer, Mieter nur bei eigener Wohnung oder Hausdokument app.get('/api/documents/:id/download', auth, async (req, res, next) => { try { const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' }) const d = rows[0] const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id if (!erlaubt) return res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' }) if (!d.file_path) return res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' }) const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden if (!fs.existsSync(vollerPfad)) return res.status(404).json({ fehler: 'Datei nicht gefunden.' }) res.download(vollerPfad, d.file_name || `${d.title}.pdf`) } catch (e) { next(e) } }) // ── Steuer & Kosten (Verwaltung) ───────────────────────────── const KOSTEN_ARTEN = [ 'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege', 'Hausreinigung', 'Hauswart', 'Aufzug', 'Versicherung', 'Grundsteuer', 'Heizung/Warmwasser', 'Kabelanschluss', 'Verwaltung', 'Instandhaltung', 'Mietausfall', 'Sonstiges', ] async function kostenGueltig(b, res) { const kostenart = String(b.kostenart || '').trim() if (!KOSTEN_ARTEN.includes(kostenart)) { res.status(400).json({ fehler: 'Ungültige Kostenart.' }); return null } const jahr = Number(b.jahr) if (!Number.isInteger(jahr) || jahr < 2000 || jahr > new Date().getFullYear() + 1) { res.status(400).json({ fehler: 'Jahr ungültig.' }); return null } const betrag = Number(b.betrag) if (!Number.isFinite(betrag) || betrag < 0) { res.status(400).json({ fehler: 'Betrag ungültig.' }); return null } // Haus folgt der Wohnung: unitId gesetzt → Wohnung muss existieren, Haus wird übernommen let einheitId = b.unitId !== undefined ? (b.unitId || null) : undefined let haus = String(b.building || '').trim() if (einheitId) { const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [einheitId]) if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null } haus = u[0].building } if (!haus) { res.status(400).json({ fehler: 'Haus fehlt.' }); return null } const { rows: h } = await pool.query('SELECT name FROM buildings WHERE name = $1', [haus]) if (!h.length) { res.status(400).json({ fehler: 'Haus unbekannt.' }); return null } return { kostenart, jahr, betrag, einheitId, haus, notiz: String(b.notiz || '').trim() } } app.post('/api/costs', auth, adminOnly, async (req, res, next) => { try { const g = await kostenGueltig(req.body || {}, res) if (!g) return const { rows } = await pool.query( `INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [`k-${Date.now()}`, g.haus, g.einheitId ?? null, g.jahr, g.kostenart, g.betrag, req.body.umlegbar !== false, g.notiz, deutschesDatumZeit()], ) res.json({ kosten: mapKosten(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/costs/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT * FROM costs WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' }) const k = rows[0] const b = req.body || {} const kandidat = { kostenart: b.kostenart !== undefined ? b.kostenart : k.kostenart, jahr: b.jahr !== undefined ? b.jahr : k.jahr, betrag: b.betrag !== undefined ? b.betrag : k.betrag, unitId: b.unitId !== undefined ? b.unitId : k.unit_id, building: b.building !== undefined ? b.building : k.building, notiz: b.notiz !== undefined ? b.notiz : k.notiz, } const g = await kostenGueltig(kandidat, res) if (!g) return const einheitId = g.einheitId === undefined ? k.unit_id : g.einheitId const { rows: aktualisiert } = await pool.query( `UPDATE costs SET building = $1, unit_id = $2, jahr = $3, kostenart = $4, betrag = $5, umlegbar = $6, notiz = $7 WHERE id = $8 RETURNING *`, [g.haus, einheitId, g.jahr, g.kostenart, g.betrag, b.umlegbar !== undefined ? Boolean(b.umlegbar) : k.umlegbar, g.notiz, req.params.id], ) res.json({ kosten: mapKosten(aktualisiert[0]) }) } catch (e) { next(e) } }) app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => { try { const { rowCount } = await pool.query('DELETE FROM costs WHERE id = $1', [req.params.id]) if (!rowCount) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' }) res.json({ ok: true }) } catch (e) { next(e) } }) // ── Beleg-Import (SparkReceipt-Webhook) ────────────────────── /** Tolerantes Extrahieren der Belegfelder: SparkReceipt schachtelt die * Daten teils unter „document", teils flach – Aliase abdecken. */ function belegFelder(body) { const d = (body && typeof body === 'object' && (body.document || body.receipt || body.data)) || body || {} const nimm = (...keys) => { for (const k of keys) { if (d[k] !== undefined && d[k] !== null && d[k] !== '') return d[k] } return undefined } const betragRoh = nimm('total', 'amount', 'betrag', 'total_amount', 'sum', 'totalAmount') const datumRoh = String(nimm('date', 'datum', 'purchase_date', 'created_at', 'document_date') ?? '') const jahrRoh = nimm('year', 'jahr') return { externeId: String(nimm('id', 'document_id', 'documentId', 'receipt_id') ?? ''), haendler: String(nimm('merchant', 'vendor', 'haendler', 'supplier', 'store_name', 'merchant_name', 'name') ?? ''), betrag: betragRoh === undefined ? null : Number(betragRoh), waehrung: String(nimm('currency', 'waehrung') ?? 'EUR'), datum: datumRoh, jahr: jahrRoh !== undefined ? Number(jahrRoh) : (datumRoh.match(/(19|20)\d{2}/) ? Number(datumRoh.match(/(19|20)\d{2}/)[0]) : null), kategorie: String(nimm('category', 'kategorie') ?? ''), notiz: String(nimm('notes', 'note', 'description', 'memo') ?? ''), dateiUrl: String(nimm('file_url', 'fileUrl', 'image_url', 'receipt_url', 'document_url', 'url') ?? ''), } } /** Volltext eines Webhook-Events (alle String-/Zahlenwerte rekursiv). */ function eventVolltext(wert, teile = []) { if (wert === null || wert === undefined) return teile if (typeof wert === 'string' || typeof wert === 'number') { teile.push(String(wert)); return teile } if (Array.isArray(wert)) { for (const x of wert) eventVolltext(x, teile); return teile } if (typeof wert === 'object') { for (const x of Object.values(wert)) eventVolltext(x, teile); return teile } return teile } /** Auto-Zuordnung eines Belegs: PRIMÄR Stromzählernummer im Volltext, * SEKUNDÄR Adresse/Hausname. Liefert { unitId?, gebaeude?, zuordnung? }. */ async function belegAutoZuordnung(text) { const fund = {} // Umlaut-Vereinheitlichung: ß↔ss, Umlaute↔Ae/Oe/Ue (OCR & Tippfehler) const normalisieren = (s) => String(s).toLowerCase() .replace(/ß/g, 'ss').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') const nadel = normalisieren(text) // Primär: Stromzählernummern (längste Nummer zuerst = spezifischste Treffer) const { rows: zaehler } = await pool.query( `SELECT nummer, unit_id, building FROM meters WHERE art = 'Strom' ORDER BY length(nummer) DESC`, ) for (const z of zaehler) { if (z.nummer && nadel.includes(normalisieren(z.nummer))) { fund.unitId = z.unit_id fund.gebaeude = z.building fund.zuordnung = 'zaehlernummer' return fund } } // Sekundär: Hausname/Adresse im Volltext const { rows: haeuser } = await pool.query('SELECT name, adresse FROM buildings') for (const h of haeuser) { const name = normalisieren((h.name || '').trim()) const adr = normalisieren((h.adresse || '').trim()) if ((name.length >= 5 && nadel.includes(name)) || (adr.length >= 5 && nadel.includes(adr))) { fund.gebaeude = h.name fund.zuordnung = 'adresse' return fund } } return fund } /** Beleg nachimportieren/analysieren: datei_url per OCR auswerten (oder den * gespeicherten Volltext erneut zuordnen) und die Wohnung/Haus-Zuordnung * aktualisieren. Eine manuelle Zuordnung ('manuell') wird nie überschrieben. */ async function belegAnalysieren(id) { const { rows } = await pool.query('SELECT * FROM belege WHERE id = $1', [id]) if (!rows.length) return null const beleg = rows[0] let text = '' if (beleg.datei_url) { try { const resp = await fetch(`${OCR_URL}/ocr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: beleg.datei_url }), signal: AbortSignal.timeout(45000), }) if (resp.ok) text = String((await resp.json()).text || '') else console.warn(`[ocr] HTTP ${resp.status} für Beleg ${id}`) } catch (e) { console.warn(`[ocr] Analyse fehlgeschlagen für Beleg ${id}:`, e.message) } } if (!text.trim()) text = beleg.rohtext || '' // Fallback: Webhook-Volltext erneut prüfen if (!text.trim()) return mapBeleg(beleg) const auto = await belegAutoZuordnung(text) const { rows: aktualisiert } = await pool.query( `UPDATE belege SET unit_id = CASE WHEN zuordnung = 'manuell' THEN unit_id ELSE $1 END, gebaude = CASE WHEN zuordnung = 'manuell' THEN gebaude ELSE $2 END, zuordnung = CASE WHEN zuordnung = 'manuell' THEN zuordnung ELSE $3 END, rohtext = $4 WHERE id = $5 RETURNING *`, [auto.unitId ?? null, auto.gebaeude ?? null, auto.zuordnung ?? null, text.slice(0, 8000), id], ) return mapBeleg(aktualisiert[0]) } app.post('/api/belege/webhook', async (req, res) => { try { if (!SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(503).json({ fehler: 'Beleg-Import ist nicht konfiguriert.' }) // SparkReceipt sendet nur eine URL (keine Custom-Headers) → Token per ?token= const token = String(req.query.token || req.headers['x-webhook-token'] || '') if (token !== SPARKRECEIPT_WEBHOOK_TOKEN) return res.status(401).json({ fehler: 'Ungültiger Token.' }) const event = String(req.body?.event || req.body?.event_type || req.body?.type || '') if (/delete/i.test(event)) return res.json({ ok: true, ignoriert: event }) // Löschungen nicht importieren const f = belegFelder(req.body) if (!f.externeId) return res.status(400).json({ fehler: 'Keine Dokument-ID im Event.' }) if (f.betrag !== null && !Number.isFinite(f.betrag)) return res.status(400).json({ fehler: 'Betrag ungültig.' }) // Auto-Zuordnung: Volltext des Events gegen Zählernummern/Adressen prüfen const volltext = eventVolltext(req.body).join(' \n ') const auto = await belegAutoZuordnung(volltext) const { rows } = await pool.query( `INSERT INTO belege (id, quelle, externe_id, haendler, betrag, waehrung, datum, jahr, kategorie, notiz, datei_url, erfasst_am, unit_id, gebaude, zuordnung, rohtext) VALUES ($1,'sparkreceipt',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT (quelle, externe_id) DO UPDATE SET haendler = EXCLUDED.haendler, betrag = EXCLUDED.betrag, waehrung = EXCLUDED.waehrung, datum = EXCLUDED.datum, jahr = EXCLUDED.jahr, kategorie = EXCLUDED.kategorie, notiz = EXCLUDED.notiz, datei_url = EXCLUDED.datei_url, unit_id = CASE WHEN belege.zuordnung = 'manuell' THEN belege.unit_id ELSE EXCLUDED.unit_id END, gebaude = CASE WHEN belege.zuordnung = 'manuell' THEN belege.gebaude ELSE EXCLUDED.gebaude END, zuordnung = CASE WHEN belege.zuordnung = 'manuell' THEN belege.zuordnung ELSE EXCLUDED.zuordnung END, rohtext = EXCLUDED.rohtext RETURNING *`, [`b-${Date.now()}`, f.externeId, f.haendler, f.betrag, f.waehrung, f.datum, f.jahr, f.kategorie, f.notiz, f.dateiUrl, deutschesDatumZeit(), auto.unitId ?? null, auto.gebaeude ?? null, auto.zuordnung ?? null, volltext.slice(0, 8000)], ) const beleg = mapBeleg(rows[0]) res.json({ ok: true, beleg, event: event || 'unknown' }) // Keine Zuordnung im Webhook-Text, aber eine Datei vorhanden → // OCR-Analyse asynchron nachschieben (blockiert den Webhook nicht) if (!beleg.zuordnung && beleg.dateiUrl) { belegAnalysieren(beleg.id).catch((e) => console.warn('[belege] Nachanalyse fehlgeschlagen:', e.message)) } } catch (e) { console.error('[belege] Webhook-Fehler:', e) res.status(500).json({ fehler: 'Interner Serverfehler.' }) } }) // Zugeordnet-Markierung für die Verwaltung (nach Übernahme als Kostenposition) app.patch('/api/belege/:id', auth, adminOnly, async (req, res, next) => { try { const zugeordnet = Boolean((req.body || {}).zugeordnet) const { rows } = await pool.query( 'UPDATE belege SET zugeordnet = $1 WHERE id = $2 RETURNING *', [zugeordnet, req.params.id], ) if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' }) res.json({ beleg: mapBeleg(rows[0]) }) } catch (e) { next(e) } }) // Beleg erneut analysieren (OCR über datei_url) und Zuordnung aktualisieren app.post('/api/belege/:id/analysieren', auth, adminOnly, async (req, res, next) => { try { const beleg = await belegAnalysieren(req.params.id) if (!beleg) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' }) res.json({ beleg }) } catch (e) { next(e) } }) // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── /** Name/Anzeigeadresse werden aus den Einzelfeldern zusammengesetzt (v1.26). */ function hausNameAus(b) { return `${String(b.strasse || '').trim()} ${String(b.hausnummer || '').trim()}`.trim() } function hausAdresseAus(b) { return `${hausNameAus(b)}, ${String(b.plz || '').trim()} ${String(b.ort || '').trim()}`.trim() } function pruefeHaus(b) { const fehler = [] const baujahr = Number(b.baujahr) const stockwerke = Number(b.stockwerke) const etagenhoehe = Number(b.etagenhoehe) const wohneinheiten = Number(b.wohneinheiten) const wohnflaeche = Number(b.wohnflaeche) const grund = b.grundstuecksflaeche === null || b.grundstuecksflaeche === undefined || b.grundstuecksflaeche === '' ? null : Number(b.grundstuecksflaeche) if (!String(b.strasse || '').trim()) fehler.push('Straße fehlt.') if (!String(b.hausnummer || '').trim()) fehler.push('Hausnummer fehlt.') if (!/^\d{5}$/.test(String(b.plz || '').trim())) fehler.push('PLZ ungültig (5 Ziffern).') if (!String(b.ort || '').trim()) fehler.push('Ort fehlt.') if (!Number.isInteger(baujahr) || baujahr < 1500 || baujahr > new Date().getFullYear() + 2) fehler.push('Baujahr ungültig.') if (!Number.isInteger(stockwerke) || stockwerke < 0 || stockwerke > 50) fehler.push('Anzahl Stockwerke ungültig.') if (!Number.isFinite(etagenhoehe) || etagenhoehe <= 0 || etagenhoehe > 10) fehler.push('Etagenhöhe ungültig.') if (!Number.isInteger(wohneinheiten) || wohneinheiten < 0) fehler.push('Anzahl Wohneinheiten ungültig.') if (!Number.isFinite(wohnflaeche) || wohnflaeche <= 0) fehler.push('Wohnfläche ungültig.') if (grund !== null && (!Number.isFinite(grund) || grund <= 0)) fehler.push('Grundstücksfläche ungültig.') return fehler } app.post('/api/buildings', auth, adminOnly, async (req, res, next) => { try { const b = req.body || {} const fehler = pruefeHaus(b) if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) const name = hausNameAus(b) const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [name]) if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Ein Haus mit dieser Straße und Hausnummer existiert bereits.' }) const grund = b.grundstuecksflaeche ? Number(b.grundstuecksflaeche) : null const { rows } = await pool.query( `INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage, baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`, [name, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(), String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(), Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.wohnflaeche), grund], ) res.json({ building: mapBuilding(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT * FROM buildings WHERE name = $1', [req.params.name]) if (!rows.length) return res.status(404).json({ fehler: 'Haus nicht gefunden.' }) const alt = mapBuilding(rows[0]) const b = { ...alt, ...(req.body || {}) } const fehler = pruefeHaus(b) if (fehler.length) return res.status(400).json({ fehler: fehler.join(' ') }) // Name folgt immer Straße + Hausnummer; die Anzeigeadresse wird neu zusammengesetzt const neuName = hausNameAus(b) if (neuName !== alt.name) { const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [neuName]) if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Ein Haus mit dieser Straße und Hausnummer existiert bereits.' }) } const grund = b.grundstuecksflaeche ? Number(b.grundstuecksflaeche) : null const client = await pool.connect() try { await client.query('BEGIN') await client.query( `UPDATE buildings SET name = $1, adresse = $2, strasse = $3, hausnummer = $4, plz = $5, ort = $6, lage = $7, baujahr = $8, stockwerke = $9, etagenhoehe = $10, wohneinheiten = $11, wohnflaeche = $12, grundstuecksflaeche = $13 WHERE name = $14`, [neuName, hausAdresseAus(b), String(b.strasse).trim(), String(b.hausnummer).trim(), String(b.plz).trim(), String(b.ort).trim(), String(b.lage || '').trim(), Number(b.baujahr), Number(b.stockwerke), Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.wohnflaeche), grund, req.params.name], ) // Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen if (neuName !== alt.name) { await client.query('UPDATE units SET building = $1 WHERE building = $2', [neuName, alt.name]) await client.query('UPDATE meters SET building = $1 WHERE building = $2', [neuName, alt.name]) } await client.query('COMMIT') } catch (e) { await client.query('ROLLBACK') throw e } finally { client.release() } const { rows: neu } = await pool.query('SELECT * FROM buildings WHERE name = $1', [neuName]) res.json({ building: mapBuilding(neu[0]) }) } catch (e) { next(e) } }) app.post('/api/units', auth, adminOnly, async (req, res, next) => { try { const u = req.body || {} const building = String(u.building || '').trim() const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building]) if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' }) if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' }) const floor = Number(u.floor) const size = Number(u.size) const coldRent = Number(u.coldRent) const warmRent = Number(u.warmRent) if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' }) if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' }) if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) const status = u.status === 'frei' ? 'frei' : 'vermietet' const id = `u-${Date.now()}` const { rows } = await pool.query( `INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [id, building, String(u.number).trim(), floor, size, coldRent, warmRent, String(u.tenant || '').trim() || null, status], ) res.json({ unit: mapUnit(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT * FROM units WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Wohnung nicht gefunden.' }) const alt = mapUnit(rows[0]) const u = { ...alt, ...(req.body || {}) } const building = String(u.building || '').trim() const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building]) if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' }) if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' }) const floor = Number(u.floor) const size = Number(u.size) const coldRent = Number(u.coldRent) const warmRent = Number(u.warmRent) if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' }) if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' }) if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' }) if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' }) const status = u.status === 'frei' ? 'frei' : 'vermietet' const { rows: neu } = await pool.query( `UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5, warm_rent = $6, tenant = $7, status = $8 WHERE id = $9 RETURNING *`, [building, String(u.number).trim(), floor, size, coldRent, warmRent, String(u.tenant || '').trim() || null, status, req.params.id], ) res.json({ unit: mapUnit(neu[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]) if (b.email !== undefined) { const email = String(b.email || '').trim() if (email && !/^\S+@\S+\.\S+$/.test(email)) return res.status(400).json({ fehler: 'Ungültige E-Mail-Adresse.' }) if (email) { const { rows: fremde } = await pool.query( 'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [email, req.params.id], ) if (fremde.length) return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' }) } await pool.query('UPDATE users SET email = $1 WHERE id = $2', [email, 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) } }) // Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die // Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist) app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => { try { const betreff = String((req.body || {}).betreff || '').trim() const text = String((req.body || {}).text || '').trim() if (!betreff || !text) return res.status(400).json({ fehler: 'Betreff und Nachricht erforderlich.' }) const { rows } = await pool.query( `SELECT * FROM users WHERE id = $1 AND rolle = 'mieter'`, [req.params.id], ) const mieter = rows[0] if (!mieter) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) if (!mieter.email) return res.status(400).json({ fehler: `${mieter.name} hat keine E-Mail-Adresse hinterlegt (Profil).` }) const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [mieter.einheit_id]) const label = mail.einheitLabel(mapUnit(einheitRows[0])) const html = mail.baueHtmlMail({ titel: 'Nachricht der Verwaltung', untertitel: label, einleitung: `Persönliche Nachricht von ${req.user.name} (Hinterstein Immobilien):`, zeilen: [ ['Wohnung', label], ['Mieter', mieter.name], ], zitat: text, aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), }) const cc = await mail.verwaltungEmails() const textVersion = `Nachricht der Verwaltung an ${mieter.name} (${label}):\n\n„${text}“\n\n` + `— Gesendet von ${req.user.name} über das Mieterportal` mail.sendeMail(mieter.email, betreff, textVersion, html, cc) res.json({ ok: true }) } 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) } }) // ── KI-Antworten (Rückkanal aus n8n) ───────────────────────── app.post('/api/ki-antwort', async (req, res, next) => { try { if (!KI_API_TOKEN || req.headers['x-api-token'] !== KI_API_TOKEN) return res.status(401).json({ fehler: 'Ungültiger Token.' }) const { threadId, text } = req.body || {} const antwort = String(text || '').trim() if (!antwort) return res.status(400).json({ fehler: 'Antworttext fehlt.' }) const { rows: threadRows } = await pool.query('SELECT * FROM threads WHERE id = $1', [String(threadId || '')]) const th = threadRows[0] if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' }) const autor = 'Hinterstein Immobilien (KI-Assistent)' await pool.query( `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,'admin',$3,$4,$5)`, [`${th.id}-ki-${Date.now()}`, th.id, autor, antwort, deutschesDatumZeit()], ) await pool.query( 'UPDATE threads SET unread_by_admin = FALSE, unread_by_tenant = TRUE WHERE id = $1', [th.id], ) // Mieter per Mail benachrichtigen (wie eine Antwort der Verwaltung) const betreff = `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„${antwort}“` const html = mail.baueHtmlMail({ titel: 'Antwort der Verwaltung', untertitel: th.subject, einleitung: `Neue Nachricht von ${autor}:`, zeilen: [ ['Wohnung', mail.einheitLabel(mapUnit(einheitRows[0]))], ['Unterhaltung', th.subject], ], zitat: antwort, aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), }) mail.benachrichtigeMieter(th.unit_id, betreff, textVersion, html) console.log(`[ki] Antwort eingespielt in Thread ${th.id}`) res.json({ ok: true }) } catch (e) { next(e) } }) // ── Nebenkostenabrechnung als PDF (v1.23) ───────────────────── app.post('/api/abrechnungen', auth, adminOnly, async (req, res, next) => { try { const unitId = String((req.body || {}).unitId || '') const jahr = Number((req.body || {}).jahr) if (!unitId || !Number.isInteger(jahr) || jahr < 2000 || jahr > 2100) return res.status(400).json({ fehler: 'Wohnung und gültiges Jahr erforderlich.' }) const { dokument, daten } = await erzeugeAbrechnung(pool, unitId, jahr) console.log(`[abrechnung] ${dokument.title} erzeugt (${dokument.size})`) res.json({ dokument: mapDocument(dokument), zusammenfassung: { summe: daten.summe, vorauszahlung: daten.vorauszahlung, differenz: daten.differenz, } }) } catch (e) { if (e.status) return res.status(e.status).json({ fehler: e.message }) next(e) } }) // ── Fehlerbehandlung ───────────────────────────────────────── app.use((err, _req, res, _next) => { console.error('[api]', err) res.status(500).json({ fehler: 'Interner Serverfehler.' }) }) // ── Start ──────────────────────────────────────────────────── /** Zerlegt „Straße Nr., PLZ Ort" in Einzelfelder (Best-Effort-Migration v1.26). */ function adresseZerlegen(adresse) { const teile = { strasse: '', hausnummer: '', plz: '', ort: '' } const linksRechts = String(adresse).split(',') const rechts = (linksRechts[1] || '').trim() const plzMatch = /(\d{5})\s*(.*)/.exec(rechts) if (plzMatch) { teile.plz = plzMatch[1]; teile.ort = plzMatch[2].trim() } const links = (linksRechts[0] || '').trim().split(/\s+/) teile.hausnummer = links.length > 1 ? links.pop() : '' teile.strasse = links.join(' ') return teile } async function haeuserFelderMigrieren() { const { rows } = await pool.query("SELECT * FROM buildings WHERE strasse = '' AND adresse <> ''") for (const b of rows) { const t = adresseZerlegen(b.adresse) await pool.query( 'UPDATE buildings SET strasse = $1, hausnummer = $2, plz = $3, ort = $4 WHERE name = $5', [t.strasse, t.hausnummer, t.plz, t.ort, b.name], ) } // Wohnfläche aus Alt-Feld quadratmeter nachziehen await pool.query('UPDATE buildings SET wohnflaeche = quadratmeter WHERE wohnflaeche IS NULL') if (rows.length) console.log(`[api] Häuser: ${rows.length} Adresse(n) in Einzelfelder zerlegt`) } 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, …)') const zaehlerGeseedet = await seedeZaehler() if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt') await zaehlerVerlauf.erstbefundFehlende() const haeuserGeseedet = await seedeHaeuser() if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt') const kostenGeseedet = await seedeKosten() if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt') await haeuserFelderMigrieren() 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) })