v1.36: Mieterverwaltung (Mieter 1:1 pro Wohnung, Kaution/Vertrag/Dokumente) + v1.37: KI-Assistent (N8N + Kimi, 6 Portal-Tools)

This commit is contained in:
2026-10-04 20:51:00 +02:00
parent 9e74813e22
commit 9d14d367ee
22 changed files with 1840 additions and 107 deletions
+13 -5
View File
@@ -66,19 +66,27 @@ async function abrechnungsDaten(pool, unitId, jahr) {
}
const summe = [...jeArt.values()].reduce((s, p) => s + p.betrag, 0)
const vorauszahlung = (Number(einheit.warm_rent) - Number(einheit.cold_rent)) * 12
// Vorauszahlung aus dem Mieterobjekt (seit v1.36, vorher am Wohnungsobjekt)
const { rows: mieterRows } = await pool.query(
'SELECT name, vorname, kaltmiete, warmmiete FROM mieter WHERE unit_id = $1', [einheit.id],
)
const nkVorauszahlung = mieterRows.length
? Number(mieterRows[0].warmmiete) - Number(mieterRows[0].kaltmiete)
: 0
const mieterName = mieterRows.length ? `${mieterRows[0].vorname} ${mieterRows[0].name}`.trim() : ''
const vorauszahlung = nkVorauszahlung * 12
const differenz = summe - vorauszahlung
const { rows: zaehler } = await pool.query(
`SELECT nummer, art, stand, stand_am FROM meters WHERE unit_id = $1 ORDER BY art, nummer`,
[einheit.id],
)
return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler }
return { einheit, jahr, positionen: [...jeArt.values()], summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName }
}
/** Zeichnet das PDF und liefert den Dateinamen (im UPLOAD_DIR). */
async function bauePdf(daten) {
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler } = daten
const { einheit, jahr, positionen, summe, vorauszahlung, differenz, zaehler, nkVorauszahlung, mieterName } = daten
const dateiName = `ab-${einheit.id}-${jahr}.pdf`
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
@@ -111,7 +119,7 @@ async function bauePdf(daten) {
y += 16
doc.text(`Objekt: ${einheit.building} · Wohnung ${einheit.number} · ${zahl(einheit.size)} m²`, 50, y)
y += 16
doc.text(`Mieter: ${einheit.tenant || '–'}`, 50, y)
doc.text(`Mieter: ${mieterName || '–'}`, 50, y)
y += 16
doc.text(`Erstellt am ${dDatum()}`, 50, y)
y += 26
@@ -152,7 +160,7 @@ async function bauePdf(daten) {
y += 17
}
zeile('Summe umlagefähige Kosten', eur(summe), true)
zeile(`Nebenkostenvorauszahlung (${eur(Number(einheit.warm_rent) - Number(einheit.cold_rent))}/Monat × 12)`, '- ' + eur(vorauszahlung))
zeile(`Nebenkostenvorauszahlung (${eur(nkVorauszahlung)}/Monat × 12)`, '- ' + eur(vorauszahlung))
y += 4
doc.moveTo(320, y - 4).lineTo(545, y - 4).lineWidth(0.5).strokeColor('#cbd5e1').stroke()
const nachzahlung = differenz > 0
+223
View File
@@ -0,0 +1,223 @@
/** KI-Assistent (v1.37): Verwaltung fragt im Portal, N8N + Kimi antworten.
*
* Ablauf:
* Chat-UI → POST /api/assist (Session, adminOnly)
* → Portal leitet Frage + Verlauf an den N8N-Webhook weiter
* → N8N (AI-Agent) ruft die Tool-Endpunkte dieses Moduls ab
* → Antwort zurück an das Portal → Chat-UI
*
* Umgebungsvariablen:
* N8N_ASSIST_WEBHOOK Webhook-URL des N8N-Assist-Workflows (Portal → N8N)
* ASSIST_SHARED_SECRET gemeinsames Geheimnis (Header x-assist-secret, Portal → N8N)
* ASSIST_API_KEY gemeinsames Geheimnis (Header x-assist-key, N8N → Portal-Tools)
*/
const { pool } = require('./db')
const N8N_ASSIST_WEBHOOK = process.env.N8N_ASSIST_WEBHOOK || ''
const ASSIST_SHARED_SECRET = process.env.ASSIST_SHARED_SECRET || ''
const ASSIST_API_KEY = process.env.ASSIST_API_KEY || ''
/** Holt die aktuellsten Einträge je Zähler (DISTINCT ON, seq absteigend). */
async function letzteEintraege(onlyAnomalien) {
const { rows } = await pool.query(
`SELECT DISTINCT ON (r.meter_id) r.meter_id, r.stand, r.stand_am, r.anomalie, r.anomalie_grund
FROM meter_readings r ORDER BY r.meter_id, r.seq DESC`,
)
return onlyAnomalien ? rows.filter((r) => r.anomalie) : rows
}
/** Registriert alle Assist-Routen auf der Express-App. */
function assistRoutenRegistrieren(app, { auth, adminOnly }) {
// Middleware für die Tool-Endpunkte: N8N authentifiziert sich per Header.
const assistKey = (req, res, next) => {
if (!ASSIST_API_KEY) {
return res.status(503).json({ fehler: 'Assistent nicht konfiguriert (ASSIST_API_KEY fehlt).' })
}
if (req.headers['x-assist-key'] !== ASSIST_API_KEY) {
return res.status(401).json({ fehler: 'Ungültiger Assistent-Schlüssel.' })
}
next()
}
// ── Chat: Frage der Verwaltung an N8N weiterleiten ──────────
app.post('/api/assist', auth, adminOnly, async (req, res, next) => {
try {
if (!N8N_ASSIST_WEBHOOK) {
return res.status(503).json({
fehler: 'Der KI-Assistent ist noch nicht konfiguriert (N8N_ASSIST_WEBHOOK fehlt).',
})
}
const frage = String(req.body?.frage || '').trim()
if (!frage) return res.status(400).json({ fehler: 'Frage fehlt.' })
const verlauf = Array.isArray(req.body?.verlauf)
? req.body.verlauf
.filter((m) => m && ['user', 'assistant'].includes(m.rolle) && String(m.text || '').trim())
.slice(-20)
.map((m) => ({ rolle: m.rolle, text: String(m.text).slice(0, 4000) }))
: []
const antwort = await fetch(N8N_ASSIST_WEBHOOK, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
...(ASSIST_SHARED_SECRET ? { 'x-assist-secret': ASSIST_SHARED_SECRET } : {}),
},
body: JSON.stringify({
frage,
verlauf,
benutzer: req.user.name,
portal: process.env.PORTAL_URL || 'https://mieterportal.hocks.eu',
}),
signal: AbortSignal.timeout(90_000),
})
if (!antwort.ok) {
return res.status(502).json({ fehler: `Assistent antwortet nicht (N8N Status ${antwort.status}).` })
}
const inhaltTyp = antwort.headers.get('content-type') || ''
const daten = inhaltTyp.includes('application/json') ? await antwort.json() : {}
const text = String(daten.antwort ?? '').trim()
if (!text) return res.status(502).json({ fehler: 'Assistent hat keine Antwort geliefert.' })
res.json({ antwort: text })
} catch (e) {
if (e.name === 'TimeoutError' || e.name === 'AbortError') {
return res.status(504).json({ fehler: 'Assistent braucht zu lange – bitte erneut versuchen.' })
}
next(e)
}
})
// ── Tools: kompakte JSON-Antworten für den N8N-AI-Agent ─────
// Alle Endpunkte liefern bewusst wenige, flache Felder (Token sparen).
/** Leerstehende / unklare Wohneinheiten. */
app.get('/api/assist/tools/leerstaende', assistKey, async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT u.building, u.number, u.floor, u.size, u.status
FROM units u
WHERE u.status IN ('leerstand', 'unklar')
ORDER BY u.building, u.floor, u.number`,
)
res.json({ anzahl: rows.length, einheiten: rows })
} catch (e) { next(e) }
})
/** Monatliches Miet-Soll (Warmmieten aller Mieter), optional je Haus. */
app.get('/api/assist/tools/mieteinnahmen', assistKey, async (req, res, next) => {
try {
const haus = String(req.query.haus || '').trim()
const params = []
let bedingung = ''
if (haus) { params.push(`%${haus}%`); bedingung = `WHERE u.building ILIKE $1` }
const { rows } = await pool.query(
`SELECT u.building, COUNT(*)::int AS mieter, SUM(m.warmmiete)::numeric(12,2) AS warmmiete_monat,
SUM(m.kaltmiete)::numeric(12,2) AS kaltmiete_monat
FROM mieter m JOIN units u ON u.id = m.unit_id
${bedingung}
GROUP BY u.building ORDER BY u.building`,
params,
)
res.json({
gesamt_warm_monat: rows.reduce((s, r) => s + Number(r.warmmiete_monat), 0).toFixed(2),
gesamt_kalt_monat: rows.reduce((s, r) => s + Number(r.kaltmiete_monat), 0).toFixed(2),
pro_haus: rows.map((r) => ({
haus: r.building, mieter: r.mieter,
warm_monat: Number(r.warmmiete_monat), kalt_monat: Number(r.kaltmiete_monat),
})),
})
} catch (e) { next(e) }
})
/** Offene Schadensmeldungen. */
app.get('/api/assist/tools/offene-schaeden', assistKey, async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT t.id, t.title, t.category, t.priority, t.status, t.created_at, t.tenant, u.building, u.number
FROM tickets t LEFT JOIN units u ON u.id = t.unit_id
WHERE t.status <> 'erledigt'
ORDER BY CASE t.priority WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, t.created_at`,
)
res.json({ anzahl: rows.length, schaeden: rows })
} catch (e) { next(e) }
})
/** Mieterkontakt nach Name oder Wohnung (personenbezogene Daten – bewusst begrenzt). */
app.get('/api/assist/tools/mieter-kontakt', assistKey, async (req, res, next) => {
try {
const name = String(req.query.name || '').trim()
const wohnung = String(req.query.wohnung || '').trim()
const params = []
const teile = []
if (name) { params.push(`%${name}%`); teile.push(`(m.name ILIKE $${params.length} OR m.vorname ILIKE $${params.length})`) }
if (wohnung) { params.push(`%${wohnung}%`); teile.push(`(u.building ILIKE $${params.length} OR u.number ILIKE $${params.length})`) }
if (!teile.length) return res.status(400).json({ fehler: 'Parameter "name" oder "wohnung" erforderlich.' })
const { rows } = await pool.query(
`SELECT m.vorname, m.name, m.telefon, m.email, u.building, u.number, m.vertragsstatus
FROM mieter m JOIN units u ON u.id = m.unit_id
WHERE ${teile.join(' AND ')}
ORDER BY m.name LIMIT 10`,
params,
)
res.json({ anzahl: rows.length, mieter: rows })
} catch (e) { next(e) }
})
/** Zähler mit Anomalie im jeweils neuesten Verlaufseintrag. */
app.get('/api/assist/tools/zaehler-anomalien', assistKey, async (_req, res, next) => {
try {
const anomalien = await letzteEintraege(true)
if (!anomalien.length) return res.json({ anzahl: 0, anomalien: [] })
const ids = anomalien.map((r) => r.meter_id)
const { rows: zaehler } = await pool.query(
`SELECT id, building, nummer, art, unit_id FROM meters WHERE id = ANY($1)`, [ids],
)
const map = new Map(zaehler.map((z) => [z.id, z]))
res.json({
anzahl: anomalien.length,
anomalien: anomalien
.map((r) => {
const z = map.get(r.meter_id)
return z && {
zaehlernummer: z.nummer, art: z.art, haus: z.building,
stand: Number(r.stand), stand_am: r.stand_am, grund: r.anomalie_grund,
}
})
.filter(Boolean),
})
} catch (e) { next(e) }
})
/** Kosten je Haus/Jahr, umlegbar vs. nicht umlegbar. */
app.get('/api/assist/tools/kosten', assistKey, async (req, res, next) => {
try {
const jahr = Number(req.query.jahr || new Date().getFullYear())
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 2100) {
return res.status(400).json({ fehler: 'Parameter "jahr" ungültig.' })
}
const haus = String(req.query.haus || '').trim()
const params = [jahr]
let bedingung = 'WHERE c.jahr = $1'
if (haus) { params.push(`%${haus}%`); bedingung += ` AND c.building ILIKE $${params.length}` }
const { rows } = await pool.query(
`SELECT c.building, c.kostenart, c.umlegbar, SUM(c.betrag)::numeric(12,2) AS betrag
FROM costs c ${bedingung}
GROUP BY c.building, c.kostenart, c.umlegbar
ORDER BY c.building, c.kostenart`,
params,
)
const umlegbar = rows.filter((r) => r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
const nichtUmlegbar = rows.filter((r) => !r.umlegbar).reduce((s, r) => s + Number(r.betrag), 0)
res.json({
jahr,
gesamt_umlegbar: umlegbar.toFixed(2),
gesamt_nicht_umlegbar: nichtUmlegbar.toFixed(2),
positionen: rows.map((r) => ({
haus: r.building, kostenart: r.kostenart,
umlegbar: r.umlegbar, betrag: Number(r.betrag),
})),
})
} catch (e) { next(e) }
})
}
module.exports = { assistRoutenRegistrieren }
+41 -4
View File
@@ -19,11 +19,18 @@ CREATE TABLE IF NOT EXISTS units (
number TEXT NOT NULL,
floor INT NOT NULL,
size NUMERIC(8,2) NOT NULL,
cold_rent NUMERIC(10,2) NOT NULL,
warm_rent NUMERIC(10,2) NOT NULL,
tenant TEXT,
status TEXT NOT NULL CHECK (status IN ('vermietet','frei'))
cold_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- Altbestand, v1.36 in Tabelle mieter ausgelagert
warm_rent NUMERIC(10,2) NOT NULL DEFAULT 0, -- dito
tenant TEXT, -- dito (Mietername, Altbestand)
status TEXT NOT NULL CHECK (status IN ('vermietet','leerstand','unklar'))
);
ALTER TABLE units ALTER COLUMN cold_rent DROP NOT NULL;
ALTER TABLE units ALTER COLUMN warm_rent DROP NOT NULL;
-- v1.36: Statuswerte erweitert (frei → leerstand, zusätzlich unklar)
ALTER TABLE units DROP CONSTRAINT IF EXISTS units_status_check;
ALTER TABLE units ADD CONSTRAINT units_status_check
CHECK (status IN ('vermietet','leerstand','unklar')) NOT VALID;
UPDATE units SET status = 'leerstand' WHERE status = 'frei';
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
@@ -256,6 +263,36 @@ CREATE TABLE IF NOT EXISTS mietvertraege (
geaendert_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id);
-- Mieter (v1.36): 1:1 zum Wohnungsobjekt – Kalt-/Warmmiete, Kontakt, Vertrag,
-- Kaution und Mietzeitraum leben hier, nicht mehr am Wohnungsobjekt.
CREATE TABLE IF NOT EXISTS mieter (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL UNIQUE REFERENCES units(id),
name TEXT NOT NULL,
vorname TEXT NOT NULL DEFAULT '',
telefon TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
kaltmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
warmmiete NUMERIC(10,2) NOT NULL DEFAULT 0,
vertragsstatus TEXT NOT NULL DEFAULT 'aktiv'
CHECK (vertragsstatus IN ('aktiv','befristet','gekuendigt')),
gekuendigt_am TEXT NOT NULL DEFAULT '', -- „gekündigt zum"
gekuendigt_von TEXT NOT NULL DEFAULT '' -- „gekündigt von": mieter | vermieter
CHECK (gekuendigt_von IN ('','mieter','vermieter')),
kaution_hoehe NUMERIC(10,2) NOT NULL DEFAULT 0,
kaution_art TEXT NOT NULL DEFAULT 'ueberwiesen'
CHECK (kaution_art IN ('bar','ueberwiesen','sonstiges')),
kaution_sonstiges TEXT NOT NULL DEFAULT '', -- Freitext bei „Andere"
kautionszahlung TEXT NOT NULL DEFAULT 'komplett'
CHECK (kautionszahlung IN ('komplett','raten')),
kaution_raten INT NOT NULL DEFAULT 0, -- Anzahl der Raten
mietbeginn_am TEXT NOT NULL DEFAULT '',
mietende_am TEXT NOT NULL DEFAULT '',
erstellt_am TEXT NOT NULL,
geaendert_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mieter_unit ON mieter(unit_id);
`
async function init() {
+268 -28
View File
@@ -17,6 +17,7 @@ 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
@@ -154,10 +155,22 @@ async function sessionAnlegen(res, userId, req) {
}
// ── 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), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent),
tenant: u.tenant, status: u.status, heizungsart: u.heizungsart ?? '',
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,
@@ -351,7 +364,7 @@ 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] =
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
@@ -391,6 +404,11 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
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 = {}
@@ -415,6 +433,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
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) }
@@ -879,13 +898,22 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
} catch (e) { next(e) }
})
// PDF-Preview für die Lightbox: gleiche Rechte wie Download, aber inline (v1.31)
/** 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', 'application/pdf')
res.setHeader('Content-Type', mimeTyp(gefunden.pfad))
res.sendFile(gefunden.pfad)
} catch (e) { next(e) }
})
@@ -1343,17 +1371,14 @@ app.post('/api/units', auth, adminOnly, async (req, res, next) => {
const warmRent = Number(u.warmRent)
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
const status = u.status === 'frei' ? 'frei' : 'vermietet'
const 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, cold_rent, warm_rent, tenant, status, heizungsart)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[id, building, String(u.number).trim(), floor, size, coldRent, warmRent,
String(u.tenant || '').trim() || null, status, heizung],
`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) }
@@ -1371,20 +1396,15 @@ app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => {
if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' })
const floor = Number(u.floor)
const size = Number(u.size)
const coldRent = Number(u.coldRent)
const warmRent = Number(u.warmRent)
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
const status = u.status === 'frei' ? 'frei' : 'vermietet'
const 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, cold_rent = $5,
warm_rent = $6, tenant = $7, status = $8, heizungsart = $9 WHERE id = $10 RETURNING *`,
[building, String(u.number).trim(), floor, size, coldRent, warmRent,
String(u.tenant || '').trim() || null, status, heizung, req.params.id],
`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) }
@@ -1407,6 +1427,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
['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}`)
@@ -1419,6 +1440,174 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
} 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 {
@@ -1737,8 +1926,14 @@ async function mietvertragGueltig(b, res) {
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]
const kaltmiete = b.kaltmiete !== undefined && b.kaltmiete !== '' ? Number(b.kaltmiete) : Number(einheit.cold_rent)
const nebenkosten = b.nebenkosten !== undefined && b.nebenkosten !== '' ? Number(b.nebenkosten) : Number(einheit.warm_rent) - Number(einheit.cold_rent)
// 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)
@@ -1935,11 +2130,31 @@ app.post('/api/mietvertraege/:id/aktivieren', auth, adminOnly, async (req, res,
[benutzerId, benutzername, vertrag.mieter_name, SHA256_0815,
vertrag.mieter_email, einheit.id, deutschesDatum(), vertrag.mieter_telefon || null],
)
// Wohnung als vermietet führen
await pool.query(
`UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`,
[vertrag.mieter_name, einheit.id],
)
// 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],
@@ -2032,6 +2247,29 @@ async function haeuserFelderMigrieren() {
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++) {
@@ -2060,6 +2298,8 @@ async function start() {
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}`))
}
+20 -6
View File
@@ -17,11 +17,24 @@ async function seed() {
await client.query('BEGIN')
for (const u of generated.units) {
// v1.36: Wohnung schlank (kein Mieter/Miete mehr am Objekt); Mieter
// werden als eigene Datensätze (Tabelle mieter) angelegt.
const status = u.status === 'vermietet' ? 'vermietet' : 'leerstand'
await client.query(
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.status],
`INSERT INTO units (id, building, number, floor, size, status) VALUES ($1,$2,$3,$4,$5,$6)`,
[u.id, u.building, u.number, u.floor, u.size, status],
)
if (u.tenant) {
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 client.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-${u.id}`, u.id, nachname, vorname, Number(u.coldRent ?? 0), Number(u.warmRent ?? 0), new Date().toLocaleDateString('de-DE')],
)
}
}
for (const t of generated.transactions) {
@@ -32,9 +45,10 @@ async function seed() {
)
}
// Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen
const { rows: unitRows } = await client.query('SELECT id, tenant FROM units')
const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant]))
// Mieter-Namen in Tickets/Threads aus den Seed-Daten nachziehen
const mieterVon = Object.fromEntries(
generated.units.filter((u) => u.tenant).map((u) => [u.id, u.tenant]),
)
for (const t of tickets) {
await client.query(