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