Files
Mieterportal/server/src/index.js
T

1568 lines
77 KiB
JavaScript
Raw Blame History

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