/** 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 { erzeugeVertragsPdf, vermieterDaten } = require('./vertrag') const zaehlerVerlauf = require('./zaehlerVerlauf') const { assistRoutenRegistrieren } = require('./assist') 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 UNIT_STATUS = ['vermietet', 'leerstand', 'unklar'] const mapUnit = (u) => ({ id: u.id, building: u.building, number: u.number, floor: u.floor, size: Number(u.size), status: u.status, heizungsart: u.heizungsart ?? '', }) const mapMieter = (m) => ({ id: m.id, unitId: m.unit_id, name: m.name, vorname: m.vorname, telefon: m.telefon, email: m.email, kaltmiete: Number(m.kaltmiete), warmmiete: Number(m.warmmiete), vertragsstatus: m.vertragsstatus, gekuendigtAm: m.gekuendigt_am, gekuendigtVon: m.gekuendigt_von, kautionHoehe: Number(m.kaution_hoehe), kautionArt: m.kaution_art, kautionSonstiges: m.kaution_sonstiges, kautionszahlung: m.kautionszahlung, kautionRaten: m.kaution_raten, mietbeginnAm: m.mietbeginn_am, mietendeAm: m.mietende_am, erstelltAm: m.erstellt_am, geaendertAm: m.geaendert_am, }) 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 mapMietvertrag = (v) => ({ id: v.id, unitId: v.unit_id, mieterName: v.mieter_name, mieterEmail: v.mieter_email, mieterTelefon: v.mieter_telefon, beginnAm: v.beginn_am, endeAm: v.ende_am, kaltmiete: Number(v.kaltmiete), nebenkosten: Number(v.nebenkosten), kaution: Number(v.kaution), status: v.status, dokumentId: v.dokument_id || null, unterschriftenDokumentId: v.unterschriften_dokument_id || null, benutzerId: v.benutzer_id || null, erstelltAm: v.erstellt_am, geaendertAm: v.geaendert_am, }) 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), heizungsart: b.heizungsart ?? '', eigenobjekt: Boolean(b.eigenobjekt), }) 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') /** ISO-Eingaben (YYYY-MM-DD, z. B. aus date-Feldern) in das deutsche Format wandeln. */ const datumDeutsch = (s) => { const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || '').trim()) return iso ? `${iso[3]}.${iso[2]}.${iso[1]}` : String(s || '').trim() } // ── 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, mietvertraege, mieter] = 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: [] }, istAdmin ? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC') : { rows: [] }, // Mieter-Stammdaten sind personenbezogen: Verwaltung sieht alle, // angemeldete Mieter nur ihren eigenen Datensatz (z. B. Miete im Dashboard). istAdmin ? pool.query('SELECT * FROM mieter ORDER BY name, vorname') : pool.query('SELECT * FROM mieter WHERE unit_id = $1', [einheit]), ]) // 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), mietvertraege: mietvertraege.rows.map(mapMietvertrag), mieter: mieter.rows.map(mapMieter), einstellungen: await vermieterDaten(pool), }) } 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) } }) // Schadensmeldung löschen inkl. verknüpfter Unterhaltung und Nachrichten (v1.33) app.delete('/api/tickets/:id', auth, adminOnly, async (req, res, next) => { const client = await pool.connect() try { await client.query('BEGIN') const { rows: threads } = await client.query('DELETE FROM threads WHERE ticket_id = $1 RETURNING id', [req.params.id]) for (const th of threads) { await client.query('DELETE FROM messages WHERE thread_id = $1', [th.id]) } const { rows } = await client.query('DELETE FROM tickets WHERE id = $1 RETURNING id', [req.params.id]) if (!rows.length) { await client.query('ROLLBACK') return res.status(404).json({ fehler: 'Schadensmeldung nicht gefunden.' }) } await client.query('COMMIT') res.json({ ok: true }) } catch (e) { await client.query('ROLLBACK').catch(() => {}) next(e) } finally { client.release() } }) // ── Zählerstände (Strom / Wasser / Gas) ───────────────────── const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] // Gängige Heizungsformen (v1.29) – Dropdown in Haus-/Wohnungsformular const HEIZUNGSARTEN = [ 'Gas-Zentralheizung', 'Öl-Zentralheizung', 'Fernwärme', 'Wärmepumpe', 'Pellets-/Holzheizung', 'Nachtspeicherheizung', 'Elektro-Direktheizung', 'Einzelraumheizung (Ofen/Kamin)', 'Sonstiges', ] /** Heizungsart validieren: leer (nicht angegeben) oder Listenwert. */ const heizungsartValid = (h) => !h || HEIZUNGSARTEN.includes(h) 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 = datumDeutsch(standAm) || deutschesDatum() if (!zaehlerVerlauf.datumGueltig(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 = datumDeutsch(standAm) || deutschesDatum() if (!zaehlerVerlauf.datumGueltig(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) } }) // Zähler komplett löschen inkl. Verlauf (v1.33) app.delete('/api/meters/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('DELETE FROM meters WHERE id = $1 RETURNING id', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Zähler nicht gefunden.' }) await pool.query('DELETE FROM meter_readings WHERE meter_id = $1', [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.' }) // Verweise aus dem Mietvertragsmodul lösen (Entwurf / Unterschrifts-PDF) await pool.query( `UPDATE mietvertraege SET dokument_id = '' WHERE dokument_id = $1; UPDATE mietvertraege SET unterschriften_dokument_id = '' WHERE unterschriften_dokument_id = $1`, [req.params.id], ) 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) } }) }) // Lädt Dokument samt Berechtigungs- und Dateiprüfung (Download/Preview/E-Mail). async function dokumentMitDatei(req, res) { const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id]) if (!rows.length) { res.status(404).json({ fehler: 'Dokument nicht gefunden.' }); return null } const d = rows[0] const erlaubt = req.user.rolle === 'verwaltung' || d.unit_id === null || d.unit_id === req.user.einheit_id if (!erlaubt) { res.status(403).json({ fehler: 'Kein Zugriff auf dieses Dokument.' }); return null } if (!d.file_path) { res.status(404).json({ fehler: 'Zu diesem Eintrag ist noch keine Datei hinterlegt.' }); return null } const vollerPfad = path.join(UPLOAD_DIR, path.basename(d.file_path)) // basename: Pfad-Traversal unterbinden if (!fs.existsSync(vollerPfad)) { res.status(404).json({ fehler: 'Datei nicht gefunden.' }); return null } return { dokument: d, pfad: vollerPfad } } // PDF-Download: Verwaltung immer, Mieter nur bei eigener Wohnung oder Hausdokument app.get('/api/documents/:id/download', auth, async (req, res, next) => { try { const gefunden = await dokumentMitDatei(req, res) if (!gefunden) return res.download(gefunden.pfad, gefunden.dokument.file_name || `${gefunden.dokument.title}.pdf`) } catch (e) { next(e) } }) /** Content-Type anhand der Dateiendung (PDF + Mieterdokument-Bilder). */ function mimeTyp(dateiPfad) { const ende = path.extname(dateiPfad || '').toLowerCase() if (ende === '.png') return 'image/png' if (ende === '.jpg' || ende === '.jpeg') return 'image/jpeg' return 'application/pdf' } // Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31); // seit v1.36 auch Bilder (Mieterdokumente wie Personalausweis-Scans). app.get('/api/documents/:id/preview', auth, async (req, res, next) => { try { const gefunden = await dokumentMitDatei(req, res) if (!gefunden) return res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(gefunden.dokument.file_name || gefunden.dokument.title)}`) res.setHeader('Content-Type', mimeTyp(gefunden.pfad)) res.sendFile(gefunden.pfad) } catch (e) { next(e) } }) // PDF per E-Mail versenden: Empfänger frei wählbar oder Mieter der Wohnung, // immer mit CC an die Verwaltung (v1.31) app.post('/api/documents/:id/email', auth, adminOnly, async (req, res, next) => { try { const gefunden = await dokumentMitDatei(req, res) if (!gefunden) return const d = gefunden.dokument const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ let empfaenger = String((req.body || {}).an || '').trim() let empfaengerName = '' if (!empfaenger && d.unit_id) { const { rows: mieter } = await pool.query( `SELECT name, email FROM users WHERE rolle = 'mieter' AND aktiv AND einheit_id = $1 AND email <> '' ORDER BY name LIMIT 1`, [d.unit_id], ) if (mieter.length) { empfaenger = mieter[0].email; empfaengerName = mieter[0].name } } if (!empfaenger || !EMAIL_RE.test(empfaenger)) { return res.status(400).json({ fehler: 'Kein gültiger Empfänger – E-Mail-Adresse angeben oder Mieter-E-Mail hinterlegen.' }) } const nachricht = String((req.body || {}).nachricht || '').trim() const betreff = String((req.body || {}).betreff || '').trim() || `Dokument: ${d.title}` const dateiname = d.file_name || `${d.title}.pdf` const cc = await mail.verwaltungEmails() const gruss = empfaengerName ? `Guten Tag ${empfaengerName},` : 'Guten Tag,' const text = `${gruss}\n\nim Anhang finden Sie das Dokument „${d.title}" aus dem Mieterportal Hinterstein Immobilien.${nachricht ? `\n\n${nachricht}` : ''}\n\nMit freundlichen Grüßen\nHinterstein Immobilien` const html = mail.baueHtmlMail({ titel: d.title, untertitel: 'Dokument aus dem Mieterportal', einleitung: gruss, zeilen: [ ['Dokument', d.title], ['Kategorie', d.category], ...(d.unit_id ? [['Wohnung', mail.einheitLabel((await pool.query('SELECT * FROM units WHERE id = $1', [d.unit_id])).rows[0])]] : []), ...(nachricht ? [['Nachricht', nachricht]] : []), ], statusBox: { text: 'Das PDF ist an diese E-Mail angehängt.', hintergrund: '#ecfdf5', farbe: '#047857' }, fussnote: 'Bei Rückfragen antworten Sie bitte direkt auf diese E-Mail oder nutzen Sie das Nachrichten-Modul im Portal.', }) await mail.sendeMail(empfaenger, betreff, text, html, cc, { dateiname, inhaltB64: fs.readFileSync(gefunden.pfad).toString('base64'), mime: 'application/pdf', }) console.log(`[mail] Dokument „${d.title}" an ${empfaenger} versendet (CC: ${cc || '–'})`) res.json({ ok: true, an: empfaenger }) } 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. Eigenobjekte (z. B. die // Rechnungsempfänger-Adresse der Verwaltung) werden übersprungen – sonst // landet jeder Beleg, auf dem die Empfängeradresse steht, am falschen Haus. const { rows: haeuser } = await pool.query( 'SELECT name, adresse FROM buildings WHERE eigenobjekt IS NOT TRUE', ) 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) } }) // Beleg löschen (v1.33) app.delete('/api/belege/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('DELETE FROM belege WHERE id = $1 RETURNING id', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' }) res.json({ ok: true }) } 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 if (!heizungsartValid(String(b.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) const heizung = String(b.heizungsart || '').trim() const { rows } = await pool.query( `INSERT INTO buildings (name, adresse, strasse, hausnummer, plz, ort, lage, baujahr, stockwerke, etagenhoehe, wohneinheiten, wohnflaeche, grundstuecksflaeche, heizungsart, eigenobjekt) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) 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, heizung, Boolean(b.eigenobjekt)], ) 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 if (!heizungsartValid(String(b.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) const heizung = String(b.heizungsart || '').trim() 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, heizungsart = $14, eigenobjekt = $15 WHERE name = $16`, [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, heizung, Boolean(b.eigenobjekt), 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) } }) // Haus löschen – nur wenn keine Wohnungen, Zähler oder Kosten mehr darauf verweisen (v1.33) app.delete('/api/buildings/:name', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [req.params.name]) if (!rows.length) return res.status(404).json({ fehler: 'Haus nicht gefunden.' }) const blocker = [] for (const [tabelle, spalte, label] of [ ['units', 'building', 'Wohnungen'], ['meters', 'building', 'Zähler'], ['costs', 'building', 'Kostenpositionen'], ]) { const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.name]) if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`) } if (blocker.length) { return res.status(400).json({ fehler: `Haus kann nicht gelöscht werden – bitte zuerst entfernen: ${blocker.join(', ')}.` }) } await pool.query('DELETE FROM buildings WHERE name = $1', [req.params.name]) res.json({ ok: true }) } 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.' }) const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand' if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) const heizung = String(u.heizungsart || '').trim() const id = `u-${Date.now()}` const { rows } = await pool.query( `INSERT INTO units (id, building, number, floor, size, status, heizungsart) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [id, building, String(u.number).trim(), floor, size, status, heizung], ) 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) 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.' }) const status = UNIT_STATUS.includes(u.status) ? u.status : 'leerstand' if (!heizungsartValid(String(u.heizungsart || '').trim())) return res.status(400).json({ fehler: 'Ungültige Heizungsart.' }) const heizung = String(u.heizungsart || '').trim() const { rows: neu } = await pool.query( `UPDATE units SET building = $1, number = $2, floor = $3, size = $4, status = $5, heizungsart = $6 WHERE id = $7 RETURNING *`, [building, String(u.number).trim(), floor, size, status, heizung, req.params.id], ) res.json({ unit: mapUnit(neu[0]) }) } catch (e) { next(e) } }) // Wohnung löschen – nur wenn keine Zähler, Dokumente, Kosten oder Mieter sie referenzieren (v1.33) app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('SELECT 1 FROM units WHERE id = $1', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Wohnung nicht gefunden.' }) const blocker = [] for (const [tabelle, spalte, label] of [ ['meters', 'unit_id', 'Zähler'], ['documents', 'unit_id', 'Dokumente'], ['costs', 'unit_id', 'Kostenpositionen'], ['users', 'einheit_id', 'zugeordnete Benutzer'], ['transactions', 'unit_id', 'Zahlungen'], ['tickets', 'unit_id', 'Schadensmeldungen'], ['threads', 'unit_id', 'Unterhaltungen'], ['registration_codes', 'einheit_id', 'Registrierungscodes'], ['belege', 'unit_id', 'Belege'], ['mietvertraege', 'unit_id', 'Mietverträge'], ['mieter', 'unit_id', 'Mieter'], ]) { const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id]) if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`) } if (blocker.length) { return res.status(400).json({ fehler: `Wohnung kann nicht gelöscht werden – bitte zuerst entfernen/zuordnen: ${blocker.join(', ')}.` }) } await pool.query('DELETE FROM units WHERE id = $1', [req.params.id]) res.json({ ok: true }) } catch (e) { next(e) } }) // ── Mieter (v1.36) ─────────────────────────────────────────── const VERTRAGSSTATUS = ['aktiv', 'befristet', 'gekuendigt'] const KAUTION_ARTEN = ['bar', 'ueberwiesen', 'sonstiges'] const KAUTIONSZAHLUNG = ['komplett', 'raten'] const MIETER_DOKUMENT_KATEGORIEN = ['Personalausweis', 'SCHUFA-Auskunft', 'Bankauszug', 'Sonstiges'] /** Uploads für Mieterdokumente: PDF + Bilder (Scan vom Handy). * Dateiname pro Mieter + Kategorie eindeutig: -. */ const uploadDokument = multer({ storage: multer.diskStorage({ destination: UPLOAD_DIR, filename: (req, file, cb) => { const kategorie = String(req.query.kategorie || 'sonstiges').toLowerCase().replace(/[^a-z0-9]+/g, '-') const ende = path.extname(file.originalname || '').toLowerCase() const endung = ['.pdf', '.jpg', '.jpeg', '.png'].includes(ende) ? ende : '.pdf' cb(null, `${req.params.id}-${kategorie}${endung}`) }, }), limits: { fileSize: UPLOAD_MAX_BYTES, files: 1 }, fileFilter: (_req, file, cb) => { const ok = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.mimetype) || /\.(pdf|jpe?g|png)$/i.test(file.originalname || '') cb(ok ? null : new Error('Nur PDF- und Bilddateien (JPG/PNG) sind erlaubt.'), ok) }, }) async function mieterGueltig(b, res) { const unitId = String(b.unitId || '').trim() const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null } const name = String(b.name || '').trim() if (!name) { res.status(400).json({ fehler: 'Name des Mieters fehlt.' }); return null } const kaltmiete = Number(b.kaltmiete ?? 0) const warmmiete = Number(b.warmmiete ?? 0) const kautionHoehe = Number(b.kautionHoehe ?? 0) if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null } if (!Number.isFinite(warmmiete) || warmmiete < 0) { res.status(400).json({ fehler: 'Warmmiete ungültig.' }); return null } if (!Number.isFinite(kautionHoehe) || kautionHoehe < 0) { res.status(400).json({ fehler: 'Kautionshöhe ungültig.' }); return null } const vertragsstatus = VERTRAGSSTATUS.includes(b.vertragsstatus) ? b.vertragsstatus : 'aktiv' const gekuendigtVon = ['', 'mieter', 'vermieter'].includes(b.gekuendigtVon) ? b.gekuendigtVon : '' const kautionArt = KAUTION_ARTEN.includes(b.kautionArt) ? b.kautionArt : 'ueberwiesen' const kautionszahlung = KAUTIONSZAHLUNG.includes(b.kautionszahlung) ? b.kautionszahlung : 'komplett' const kautionRaten = Number(b.kautionRaten ?? 0) const datum = (wert) => datumDeutsch(wert) || String(wert || '').trim() return { unitId, einheit: u[0], name, vorname: String(b.vorname || '').trim(), telefon: String(b.telefon || '').trim(), email: String(b.email || '').trim(), kaltmiete, warmmiete, vertragsstatus, gekuendigtAm: vertragsstatus === 'gekuendigt' ? datum(b.gekuendigtAm) : '', gekuendigtVon: vertragsstatus === 'gekuendigt' ? gekuendigtVon : '', kautionHoehe, kautionArt, kautionSonstiges: kautionArt === 'sonstiges' ? String(b.kautionSonstiges || '').trim() : '', kautionszahlung, kautionRaten: kautionszahlung === 'raten' && Number.isInteger(kautionRaten) && kautionRaten > 0 ? kautionRaten : 0, mietbeginnAm: datum(b.mietbeginnAm), mietendeAm: datum(b.mietendeAm), } } app.post('/api/mieter', auth, adminOnly, async (req, res, next) => { try { const g = await mieterGueltig(req.body || {}, res) if (!g) return const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1', [g.unitId]) if (belegt.length) return res.status(400).json({ fehler: 'Zu dieser Wohnung ist bereits ein Mieter angelegt.' }) const id = `mi-${Date.now()}` const { rows } = await pool.query( `INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete, vertragsstatus, gekuendigt_am, gekuendigt_von, kaution_hoehe, kaution_art, kaution_sonstiges, kautionszahlung, kaution_raten, mietbeginn_am, mietende_am, erstellt_am, geaendert_am) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19) RETURNING *`, [id, g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete, g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon, g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten, g.mietbeginnAm, g.mietendeAm, deutschesDatum()], ) // Wohnung folgt dem Mieter-Status await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['vermietet', g.unitId]) res.json({ mieter: mapMieter(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/mieter/:id', auth, adminOnly, async (req, res, next) => { try { const { rows: alt } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]) if (!alt.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) const g = await mieterGueltig({ ...mapMieter(alt[0]), ...(req.body || {}) }, res) if (!g) return const { rows: belegt } = await pool.query('SELECT 1 FROM mieter WHERE unit_id = $1 AND id <> $2', [g.unitId, req.params.id]) if (belegt.length) return res.status(400).json({ fehler: 'Die gewählte Wohnung ist bereits anderweitig vergeben.' }) const { rows } = await pool.query( `UPDATE mieter SET unit_id=$1, name=$2, vorname=$3, telefon=$4, email=$5, kaltmiete=$6, warmmiete=$7, vertragsstatus=$8, gekuendigt_am=$9, gekuendigt_von=$10, kaution_hoehe=$11, kaution_art=$12, kaution_sonstiges=$13, kautionszahlung=$14, kaution_raten=$15, mietbeginn_am=$16, mietende_am=$17, geaendert_am=$18 WHERE id=$19 RETURNING *`, [g.unitId, g.name, g.vorname, g.telefon, g.email, g.kaltmiete, g.warmmiete, g.vertragsstatus, g.gekuendigtAm, g.gekuendigtVon, g.kautionHoehe, g.kautionArt, g.kautionSonstiges, g.kautionszahlung, g.kautionRaten, g.mietbeginnAm, g.mietendeAm, deutschesDatum(), req.params.id], ) res.json({ mieter: mapMieter(rows[0]) }) } catch (e) { next(e) } }) app.delete('/api/mieter/:id', auth, adminOnly, async (req, res, next) => { try { const { rows } = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING unit_id', [req.params.id]) if (!rows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) await pool.query('UPDATE units SET status = $1 WHERE id = $2', ['leerstand', rows[0].unit_id]) // Mieterdokumente (Scans/Ausweise) mitlöschen: DB-Zeilen + Dateien const { rows: dokus } = await pool.query( `DELETE FROM documents WHERE id LIKE 'd-md-' || $1 || '-%' RETURNING file_path`, [req.params.id], ) for (const d of dokus) { if (d.file_path) { try { fs.unlinkSync(path.join(UPLOAD_DIR, path.basename(String(d.file_path)))) } catch { /* Datei bereits weg */ } } } res.json({ ok: true }) } catch (e) { next(e) } }) // Mieterdokument hochladen (Personalausweis, SCHUFA, Bankauszug …) – pro // Mieter und Kategorie gibt es genau einen Slot; erneutes Hochladen ersetzt. app.post('/api/mieter/:id/dokumente', auth, adminOnly, async (req, res, next) => { try { const { rows: mieterRows } = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]) if (!mieterRows.length) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) const m = mieterRows[0] const kategorie = String(req.query.kategorie || 'Sonstiges') if (!MIETER_DOKUMENT_KATEGORIEN.includes(kategorie)) { return res.status(400).json({ fehler: 'Ungültige Dokumentenkategorie.' }) } uploadDokument.single('file')(req, res, async (fehler) => { try { if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF und Bilder (JPG/PNG) bis 25 MB erlaubt.' }) if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' }) const groesseBytes = req.file.size const groesse = groesseBytes > 1024 ? `${(groesseBytes / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB` : `${groesseBytes} B` const titel = `${kategorie} – ${[m.vorname, m.name].filter(Boolean).join(' ')}` const dokumentId = `d-md-${m.id}-${kategorie.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` const dateiName = req.file.filename // aus multer storage: -. const originalName = String(req.file.originalname || dateiName) const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId]) if (vorhanden.length) { // Ersetzen: auch file_path tauschen (Endung kann von PDF zu Bild wechseln) await pool.query( `UPDATE documents SET title = $1, file_name = $2, size = $3, date = $4, file_path = $5 WHERE id = $6`, [titel, originalName, groesse, deutschesDatum(), dateiName, dokumentId], ) } else { await pool.query( `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [dokumentId, m.unit_id, titel, kategorie, groesse, deutschesDatum(), originalName, dateiName], ) } res.json({ dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) }) } catch (e) { next(e) } }) } 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) } }) // Benutzer löschen: eigenes Konto und den letzten aktiven Verwalter schützen (v1.33) app.delete('/api/users/:id', auth, adminOnly, async (req, res, next) => { try { if (req.params.id === req.user.id) return res.status(400).json({ fehler: 'Das eigene Konto kann nicht gelöscht werden.' }) 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 (rows[0].rolle === 'verwaltung' && rows[0].aktiv) { const { rows: rest } = await pool.query( `SELECT COUNT(*)::int AS n FROM users WHERE rolle = 'verwaltung' AND aktiv AND id <> $1`, [req.params.id], ) if (rest[0].n === 0) return res.status(400).json({ fehler: 'Der letzte aktive Verwaltungsbenutzer kann nicht gelöscht werden.' }) } await pool.query('DELETE FROM sessions WHERE user_id = $1', [req.params.id]) await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]) res.json({ ok: true }) } 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) } }) // ── Mietverträge (v1.35) ───────────────────────────────────── const SHA256_0815 = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815") /** Pflichtfelren prüfen; liefert normierte Werte oder null (+Response bereits gesendet). */ async function mietvertragGueltig(b, res) { const mieterName = String(b.mieterName || '').trim() if (!mieterName) { res.status(400).json({ fehler: 'Name des zukünftigen Mieters fehlt.' }); return null } const unitId = String(b.unitId || '').trim() const { rows: u } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId]) if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null } const einheit = u[0] // Miet-Voreinstellungen aus dem Mieterobjekt (seit v1.36; z. B. Nachmietung) const { rows: mieterRows } = await pool.query( 'SELECT kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [unitId], ) const mietKalt = mieterRows.length ? Number(mieterRows[0].kaltmiete) : 0 const mietNk = mieterRows.length ? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete) : 0 const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : mietKalt const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : mietNk if (!Number.isFinite(kaltmiete) || kaltmiete < 0) { res.status(400).json({ fehler: 'Kaltmiete ungültig.' }); return null } if (!Number.isFinite(nebenkosten) || nebenkosten < 0) { res.status(400).json({ fehler: 'Nebenkosten ungültig.' }); return null } const kaution = Number(b.kaution ?? 0) if (!Number.isFinite(kaution) || kaution < 0) { res.status(400).json({ fehler: 'Kaution ungültig.' }); return null } const beginnAm = datumDeutsch(b.beginnAm) || String(b.beginnAm || '').trim() if (!beginnAm) { res.status(400).json({ fehler: 'Mietbeginn fehlt.' }); return null } const endeAm = datumDeutsch(b.endeAm) || String(b.endeAm || '').trim() const mieterEmail = String(b.mieterEmail || '').trim() const mieterTelefon = String(b.mieterTelefon || '').trim() return { unitId, einheit, mieterName, mieterEmail, mieterTelefon, beginnAm, endeAm, kaltmiete, nebenkosten, kaution } } async function mietvertragLaden(id, res) { const { rows } = await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [id]) if (!rows.length) { res.status(404).json({ fehler: 'Mietvertrag nicht gefunden.' }); return null } return rows[0] } app.post('/api/mietvertraege', auth, adminOnly, async (req, res, next) => { try { const g = await mietvertragGueltig(req.body || {}, res) if (!g) return const id = `mv-${Date.now()}` const { rows } = await pool.query( `INSERT INTO mietvertraege (id, unit_id, mieter_name, mieter_email, mieter_telefon, beginn_am, ende_am, kaltmiete, nebenkosten, kaution, status, erstellt_am, geaendert_am) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'entwurf',$11,$11) RETURNING *`, [id, g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm, g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum()], ) res.json({ mietvertrag: mapMietvertrag(rows[0]) }) } catch (e) { next(e) } }) app.patch('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => { try { const alt = await mietvertragLaden(req.params.id, res) if (!alt) return if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) const b = { ...mapMietvertrag(alt), ...(req.body || {}) } const g = await mietvertragGueltig(b, res) if (!g) return const { rows } = await pool.query( `UPDATE mietvertraege SET unit_id=$1, mieter_name=$2, mieter_email=$3, mieter_telefon=$4, beginn_am=$5, ende_am=$6, kaltmiete=$7, nebenkosten=$8, kaution=$9, geaendert_am=$10 WHERE id=$11 RETURNING *`, [g.unitId, g.mieterName, g.mieterEmail, g.mieterTelefon, g.beginnAm, g.endeAm, g.kaltmiete, g.nebenkosten, g.kaution, deutschesDatum(), req.params.id], ) res.json({ mietvertrag: mapMietvertrag(rows[0]) }) } catch (e) { next(e) } }) app.delete('/api/mietvertraege/:id', auth, adminOnly, async (req, res, next) => { try { const alt = await mietvertragLaden(req.params.id, res) if (!alt) return if (alt.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht gelöscht werden.' }) await pool.query('DELETE FROM mietvertraege WHERE id = $1', [req.params.id]) res.json({ ok: true }) } catch (e) { next(e) } }) // Entwurf als PDF erzeugen/aktualisieren (Dokumentenmodul) app.post('/api/mietvertraege/:id/entwurf', auth, adminOnly, async (req, res, next) => { try { const vertrag = await mietvertragLaden(req.params.id, res) if (!vertrag) return if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) const dokument = await erzeugeVertragsPdf(pool, vertrag) res.json({ dokument: mapDocument(dokument) }) } catch (e) { if (e.status) return res.status(e.status).json({ fehler: e.message }) next(e) } }) // Entwurf per E-Mail an den zukünftigen Mieter versenden (CC: Verwaltung) app.post('/api/mietvertraege/:id/versenden', auth, adminOnly, async (req, res, next) => { try { let vertrag = await mietvertragLaden(req.params.id, res) if (!vertrag) return if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht erneut versendet werden.' }) if (!vertrag.mieter_email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email)) return res.status(400).json({ fehler: 'E-Mail-Adresse des zukünftigen Mieters fehlt oder ist ungültig.' }) // PDF auf aktuellem Datenstand neu erzeugen (ersetzt ggf. altes Entwurfs-PDF) await erzeugeVertragsPdf(pool, vertrag) vertrag = (await pool.query('SELECT * FROM mietvertraege WHERE id = $1', [req.params.id])).rows[0] const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) const einheit = einheiten[0] const betreff = `Ihr Mietvertragsentwurf – ${einheit.building}, Wohnung ${einheit.number}` const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu' const text = `Guten Tag ${vertrag.mieter_name},\n\nanbei erhalten Sie den Entwurf Ihres Mietvertrags für die Wohnung ${einheit.number} in ${einheit.building}. ` + 'Bitte prüfen Sie den Vertrag; um ihn zu bestätigen, unterschreiben Sie ihn und reichen Sie ihn bei der Verwaltung ein.\n\n' + `Mit freundlichen Grüßen\nIhre Verwaltung` const html = mail.baueHtmlMail({ titel: 'Mietvertragsentwurf', untertitel: `${einheit.building}, Wohnung ${einheit.number}`, einleitung: `Guten Tag ${vertrag.mieter_name},`, zeilen: [ ['Objekt', `${einheit.building}, Whg. ${einheit.number}`], ['Mietbeginn', vertrag.beginn_am], ['Kaltmiete', `${Number(vertrag.kaltmiete).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`], ['Kaution', `${Number(vertrag.kaution).toLocaleString('de-DE', { minimumFractionDigits: 2 })} €`], ], statusBox: { text: 'Der Vertragsentwurf (PDF) ist an diese E-Mail angehängt.', hintergrund: '#ecfdf5', farbe: '#047857' }, fussnote: 'Bei Rückfragen antworten Sie bitte direkt auf diese E-Mail.', }) await mail.sendeMail(vertrag.mieter_email, betreff, text, html, await mail.verwaltungEmails(), { dateiname: `Mietvertrag-${einheit.building.replace(/\s+/g, '_')}-Whg${einheit.number}.pdf`, inhaltB64: fs.readFileSync(path.join(UPLOAD_DIR, path.basename(`mv-${vertrag.id}.pdf`))).toString('base64'), mime: 'application/pdf', }) const { rows } = await pool.query( `UPDATE mietvertraege SET status = 'versendet', geaendert_am = $1 WHERE id = $2 RETURNING *`, [deutschesDatum(), req.params.id], ) console.log(`[vertrag] Entwurf ${vertrag.id} an ${vertrag.mieter_email} versendet`) res.json({ mietvertrag: mapMietvertrag(rows[0]) }) } catch (e) { next(e) } }) // Unterschriebenen Vertrag (Scan/PDF) hochladen → Status „unterschrieben" app.post('/api/mietvertraege/:id/unterschrift', auth, adminOnly, async (req, res, next) => { try { const vertrag = await mietvertragLaden(req.params.id, res) if (!vertrag) return if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Aktive Verträge können nicht mehr bearbeitet werden.' }) upload.single('file')(req, res, async (fehler) => { try { if (fehler) return res.status(400).json({ fehler: 'Upload fehlgeschlagen – nur PDF-Dateien bis 25 MB erlaubt.' }) if (!req.file) return res.status(400).json({ fehler: 'Keine Datei übertragen.' }) const groesseBytes = req.file.size const groesse = groesseBytes > 1024 ? `${(groesseBytes / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} KB` : `${groesseBytes} B` const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) const einheit = einheiten[0] const dokumentTitel = `Mietvertrag (unterschrieben) – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}` let dokumentId = vertrag.unterschriften_dokument_id const { rows: vorhanden } = await pool.query('SELECT id FROM documents WHERE id = $1', [dokumentId || '']) if (vorhanden.length) { await pool.query( `UPDATE documents SET title = $1, file_name = $1, size = $2, date = $3, unit_id = $4 WHERE id = $5`, [dokumentTitel, groesse, deutschesDatum(), einheit.id, dokumentId], ) } else { dokumentId = `d-mvu-${vertrag.id}` await pool.query( `INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path) VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$3,$6)`, [dokumentId, einheit.id, dokumentTitel, groesse, deutschesDatum(), `${vertrag.id}.pdf`], ) } const { rows } = await pool.query( `UPDATE mietvertraege SET status = 'unterschrieben', unterschriften_dokument_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`, [dokumentId, deutschesDatum(), req.params.id], ) res.json({ mietvertrag: mapMietvertrag(rows[0]), dokument: mapDocument((await pool.query('SELECT * FROM documents WHERE id = $1', [dokumentId])).rows[0]) }) } catch (e) { next(e) } }) } catch (e) { next(e) } }) // Mieterkonto anlegen + Wohnung zuweisen (manuell ausgelöst) → Status „aktiv" app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res, next) => { try { const vertrag = await mietvertragLaden(req.params.id, res) if (!vertrag) return if (vertrag.status === 'aktiv') return res.status(400).json({ fehler: 'Der Vertrag ist bereits aktiv.' }) if (vertrag.status !== 'unterschrieben') return res.status(400).json({ fehler: 'Erst den unterschriebenen Vertrag hochladen, dann aktivieren.' }) const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id]) if (!einheiten.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' }) const einheit = einheiten[0] // Benutzername aus Name ableiten (vorname.nachname), ggf. numerisch ergänzen const teile = vertrag.mieter_name.trim().split(/\s+/) const norm = (s) => s.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9.\-]/g, '') const basis = teile.length > 1 ? `${norm(teile[0])}.${norm(teile[teile.length - 1])}` : norm(teile[0]) let benutzername = basis || `mieter.${Date.now()}` let versuch = 0 while (true) { const { rows: belegt } = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzername]) if (!belegt.length) break versuch += 1 benutzername = `${basis}${versuch + 1}` if (versuch > 20) return res.status(400).json({ fehler: 'Kein freier Benutzername ableitbar.' }) } const benutzerId = `b-${Date.now()}` await pool.query( `INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am, telefon) VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7,$8)`, [benutzerId, benutzername, vertrag.mieter_name, SHA256_0815, vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null], ) // Mieterobjekt anlegen/aktualisieren + Wohnung als vermietet führen (v1.36) const nameTeile = vertrag.mieter_name.trim().split(/\s+/) const vorname = nameTeile.length > 1 ? nameTeile.slice(0, -1).join(' ') : '' const nachname = nameTeile.length > 1 ? nameTeile[nameTeile.length - 1] : nameTeile[0] const { rows: mieterVorhanden } = await pool.query('SELECT id FROM mieter WHERE unit_id = $1', [einheit.id]) if (mieterVorhanden.length) { await pool.query( `UPDATE mieter SET name=$1, vorname=$2, email=$3, telefon=$4, kaltmiete=$5, warmmiete=$6, vertragsstatus='aktiv', mietbeginn_am=$7, geaendert_am=$8 WHERE unit_id=$9`, [nachname, vorname, vertrag.mieter_email, vertrag.mieter_telefon, vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten), vertrag.beginn_am, deutschesDatum(), einheit.id], ) } else { const mieterId = `mi-${Date.now()}` await pool.query( `INSERT INTO mieter (id, unit_id, name, vorname, telefon, email, kaltmiete, warmmiete, vertragsstatus, mietbeginn_am, erstellt_am, geaendert_am) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'aktiv',$9,$10,$10)`, [mieterId, einheit.id, nachname, vorname, vertrag.mieter_telefon, vertrag.mieter_email, vertrag.kaltmiete, Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten), vertrag.beginn_am, deutschesDatum()], ) } await pool.query(`UPDATE units SET status = 'vermietet' WHERE id = $1`, [einheit.id]) const { rows } = await pool.query( `UPDATE mietvertraege SET status = 'aktiv', benutzer_id = $1, geaendert_am = $2 WHERE id = $3 RETURNING *`, [benutzerId, deutschesDatum(), req.params.id], ) // Zugangsdaten an den Mieter mailen (wenn E-Mail hinterlegt) if (vertrag.mieter_email && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(vertrag.mieter_email)) { const portalUrl = process.env.PORTAL_URL || 'https://mieterportal.hocks.eu' const text = `Guten Tag ${vertrag.mieter_name},\n\nIhr Mietvertrag ist aktiv – willkommen bei Hinterstein Immobilien! ` + `Ihr Zugang zum Mieterportal wurde eingerichtet:\n\nBenutzername: ${benutzername}\nPasswort: 0815\n\n` + `Bitte melden Sie sich an und ändern Sie Ihr Passwort im Profil.\n${portalUrl}\n\nMit freundlichen Grüßen\nIhre Verwaltung` const html = mail.baueHtmlMail({ titel: 'Ihr Mieterportal-Zugang ist bereit', untertitel: `${einheit.building}, Wohnung ${einheit.number}`, einleitung: `Guten Tag ${vertrag.mieter_name},`, zeilen: [ ['Objekt', `${einheit.building}, Whg. ${einheit.number}`], ['Benutzername', benutzername], ['Passwort', '0815 (bitte nach dem ersten Login ändern)'], ], statusBox: { text: 'Der unterschriebene Mietvertrag ist im Portal hinterlegt.', hintergrund: '#ecfdf5', farbe: '#047857' }, aktionenHtml: mail.buttonHtml('Zum Mieterportal', portalUrl), fussnote: 'Bei Fragen wenden Sie sich bitte an die Verwaltung.', }) await mail.sendeMail(vertrag.mieter_email, 'Ihr Mieterportal-Zugang – Hinterstein Immobilien', text, html, await mail.verwaltungEmails()) } console.log(`[vertrag] ${vertrag.id} aktiviert – Mieterkonto ${benutzername} (${benutzerId}) angelegt`) res.json({ mietvertrag: mapMietvertrag(rows[0]), benutzername }) } catch (e) { next(e) } }) // Vermieterdaten (Schlüssel-Wert) lesen/pflegen – Basis der Vertrags-PDFs app.get('/api/einstellungen', auth, adminOnly, async (req, res, next) => { try { const daten = await vermieterDaten(pool) res.json({ einstellungen: daten }) } catch (e) { next(e) } }) const EINSTELLUNGEN_KEYS = [ 'vermieter_name', 'vermieter_strasse', 'vermieter_hausnummer', 'vermieter_plz', 'vermieter_ort', 'vermieter_telefon', 'vermieter_email', 'vermieter_bankname', 'vermieter_iban', ] app.put('/api/einstellungen', auth, adminOnly, async (req, res, next) => { try { const b = req.body || {} for (const key of EINSTELLUNGEN_KEYS) { if (b[key] !== undefined) { await pool.query( `INSERT INTO einstellungen (schluessel, wert) VALUES ($1, $2) ON CONFLICT (schluessel) DO UPDATE SET wert = EXCLUDED.wert`, [key, String(b[key] ?? '').trim()], ) } } res.json({ einstellungen: await vermieterDaten(pool) }) } catch (e) { 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`) } /** Einmal-Migration v1.36: Mieterdaten aus dem Wohnungsobjekt (Altbestand: * units.tenant/cold_rent/warm_rent) in die neue Tabelle mieter überführen. */ async function mieterAusUnitsMigrieren() { const { rows } = await pool.query( `SELECT u.* FROM units u WHERE u.tenant IS NOT NULL AND u.tenant <> '' AND NOT EXISTS (SELECT 1 FROM mieter m WHERE m.unit_id = u.id)`, ) for (const u of rows) { const teile = String(u.tenant).trim().split(/\s+/) const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : '' const nachname = teile.length > 1 ? teile[teile.length - 1] : teile[0] await pool.query( `INSERT INTO mieter (id, unit_id, name, vorname, kaltmiete, warmmiete, vertragsstatus, mietbeginn_am, erstellt_am, geaendert_am) VALUES ($1,$2,$3,$4,$5,$6,'aktiv','',$7,$7)`, [`mi-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, u.id, nachname, vorname, Number(u.cold_rent ?? 0), Number(u.warm_rent ?? 0), deutschesDatum()], ) } if (rows.length) console.log(`[api] Mieter: ${rows.length} Datensatz/Datensätze aus Wohnungen übernommen`) } 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() await mieterAusUnitsMigrieren() assistRoutenRegistrieren(app, { auth, adminOnly }) 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) })