Files
Mieterportal/server/src/index.js
T

2311 lines
117 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 { 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 (<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: <mieterId>-<kategorie>.<endung> */
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: <mieterId>-<kategorie>.<endung>
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)
})