2311 lines
117 KiB
JavaScript
2311 lines
117 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 { 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)
|
||
})
|