v1.10+v1.11: Bestandsverwaltung (Häuser & Wohnungen) + KI-Antworten via n8n/Ollama
This commit is contained in:
@@ -127,6 +127,16 @@ CREATE TABLE IF NOT EXISTS meters (
|
||||
geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
name TEXT PRIMARY KEY, -- eindeutiger Hausname (z. B. "Hauptstraße 12")
|
||||
adresse TEXT NOT NULL,
|
||||
baujahr INT NOT NULL,
|
||||
stockwerke INT NOT NULL,
|
||||
etagenhoehe NUMERIC(4,2) NOT NULL, -- Deckenhöhe in m
|
||||
wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll)
|
||||
quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m²
|
||||
);
|
||||
`
|
||||
|
||||
async function init() {
|
||||
|
||||
+222
-2
@@ -9,17 +9,41 @@
|
||||
const express = require('express')
|
||||
const crypto = require('crypto')
|
||||
const { pool, init, sessionsAufraeumen } = require('./db')
|
||||
const { seed, seedeZaehler } = require('./seed')
|
||||
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
||||
const mail = require('./mail')
|
||||
|
||||
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 || ''
|
||||
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '256kb' }))
|
||||
app.set('trust proxy', true)
|
||||
|
||||
// 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 || ''
|
||||
@@ -115,6 +139,11 @@ 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,
|
||||
})
|
||||
const mapBuilding = (b) => ({
|
||||
name: b.name, adresse: b.adresse, baujahr: b.baujahr, stockwerke: b.stockwerke,
|
||||
etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten,
|
||||
quadratmeter: Number(b.quadratmeter),
|
||||
})
|
||||
|
||||
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
|
||||
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
||||
@@ -218,7 +247,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] =
|
||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings] =
|
||||
await Promise.all([
|
||||
pool.query('SELECT * FROM units ORDER BY id'),
|
||||
istAdmin
|
||||
@@ -246,6 +275,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM meters ORDER BY building, nummer')
|
||||
: pool.query('SELECT * FROM meters WHERE unit_id = $1 ORDER BY nummer', [einheit]),
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM buildings ORDER BY name')
|
||||
: { rows: [] },
|
||||
])
|
||||
// Threads mit ihren Nachrichten zusammenführen
|
||||
const nachrichtenProThread = {}
|
||||
@@ -266,6 +298,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
benutzer: users.rows.map(oeffentlicherBenutzer),
|
||||
codes: codes.rows.map(mapCode),
|
||||
meters: meters.rows.map(mapMeter),
|
||||
buildings: buildings.rows.map(mapBuilding),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -330,6 +363,20 @@ app.post('/api/tickets', auth, async (req, res, next) => {
|
||||
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') {
|
||||
@@ -435,6 +482,137 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
|
||||
res.json({ meter: mapMeter(aktualisiert[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||
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 quadratmeter = Number(b.quadratmeter)
|
||||
if (!String(b.name || '').trim()) fehler.push('Hausname fehlt.')
|
||||
if (!String(b.adresse || '').trim()) fehler.push('Adresse 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('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('Wohneinheiten ungültig.')
|
||||
if (!Number.isFinite(quadratmeter) || quadratmeter <= 0) fehler.push('Quadratmeter 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 = String(b.name).trim()
|
||||
const vorhanden = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [name])
|
||||
if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieses Haus existiert bereits.' })
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[name, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke),
|
||||
Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter)],
|
||||
)
|
||||
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(' ') })
|
||||
const neuName = String(b.name).trim()
|
||||
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: 'Dieser Hausname ist bereits vergeben.' })
|
||||
}
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(
|
||||
`UPDATE buildings SET name = $1, adresse = $2, baujahr = $3, stockwerke = $4,
|
||||
etagenhoehe = $5, wohneinheiten = $6, quadratmeter = $7 WHERE name = $8`,
|
||||
[neuName, String(b.adresse).trim(), Number(b.baujahr), Number(b.stockwerke),
|
||||
Number(b.etagenhoehe), Number(b.wohneinheiten), Number(b.quadratmeter), req.params.name],
|
||||
)
|
||||
// Haus umbenannt → Bezüge in Wohnungen und Zählern mitziehen
|
||||
if (neuName !== alt.name) {
|
||||
await client.query('UPDATE units SET building = $1 WHERE building = $2', [neuName, alt.name])
|
||||
await client.query('UPDATE meters SET building = $1 WHERE building = $2', [neuName, alt.name])
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK')
|
||||
throw e
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
const { rows: neu } = await pool.query('SELECT * FROM buildings WHERE name = $1', [neuName])
|
||||
res.json({ building: mapBuilding(neu[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
app.post('/api/units', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const u = req.body || {}
|
||||
const building = String(u.building || '').trim()
|
||||
const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building])
|
||||
if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' })
|
||||
if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' })
|
||||
const floor = Number(u.floor)
|
||||
const size = Number(u.size)
|
||||
const coldRent = Number(u.coldRent)
|
||||
const warmRent = Number(u.warmRent)
|
||||
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
|
||||
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
|
||||
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
|
||||
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
|
||||
const status = u.status === 'frei' ? 'frei' : 'vermietet'
|
||||
const id = `u-${Date.now()}`
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
|
||||
[id, building, String(u.number).trim(), floor, size, coldRent, warmRent,
|
||||
String(u.tenant || '').trim() || null, status],
|
||||
)
|
||||
res.json({ unit: mapUnit(rows[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM units WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return res.status(404).json({ fehler: 'Wohnung nicht gefunden.' })
|
||||
const alt = mapUnit(rows[0])
|
||||
const u = { ...alt, ...(req.body || {}) }
|
||||
const building = String(u.building || '').trim()
|
||||
const { rows: h } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [building])
|
||||
if (!h.length) return res.status(400).json({ fehler: 'Haus unbekannt – bitte erst anlegen.' })
|
||||
if (!String(u.number || '').trim()) return res.status(400).json({ fehler: 'Wohnungsnummer fehlt.' })
|
||||
const floor = Number(u.floor)
|
||||
const size = Number(u.size)
|
||||
const coldRent = Number(u.coldRent)
|
||||
const warmRent = Number(u.warmRent)
|
||||
if (!Number.isInteger(floor) || floor < -2 || floor > 50) return res.status(400).json({ fehler: 'Etage ungültig.' })
|
||||
if (!Number.isFinite(size) || size <= 0 || size > 5000) return res.status(400).json({ fehler: 'Wohnfläche ungültig.' })
|
||||
if (!Number.isFinite(coldRent) || coldRent < 0) return res.status(400).json({ fehler: 'Kaltmiete ungültig.' })
|
||||
if (!Number.isFinite(warmRent) || warmRent < 0) return res.status(400).json({ fehler: 'Warmmiete ungültig.' })
|
||||
const status = u.status === 'frei' ? 'frei' : 'vermietet'
|
||||
const { rows: neu } = await pool.query(
|
||||
`UPDATE units SET building = $1, number = $2, floor = $3, size = $4, cold_rent = $5,
|
||||
warm_rent = $6, tenant = $7, status = $8 WHERE id = $9 RETURNING *`,
|
||||
[building, String(u.number).trim(), floor, size, coldRent, warmRent,
|
||||
String(u.tenant || '').trim() || null, status, req.params.id],
|
||||
)
|
||||
res.json({ unit: mapUnit(neu[0]) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ── Nachrichten (Threads) ────────────────────────────────────
|
||||
app.post('/api/threads', auth, async (req, res, next) => {
|
||||
try {
|
||||
@@ -614,6 +792,46 @@ app.post('/api/codes', auth, adminOnly, async (req, res, next) => {
|
||||
} 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) }
|
||||
})
|
||||
|
||||
// ── Fehlerbehandlung ─────────────────────────────────────────
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[api]', err)
|
||||
@@ -643,6 +861,8 @@ async function start() {
|
||||
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')
|
||||
const haeuserGeseedet = await seedeHaeuser()
|
||||
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
|
||||
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
|
||||
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
||||
}
|
||||
|
||||
@@ -168,4 +168,10 @@ const meters = [
|
||||
{ id: 'z-9', unit_id: null, building: 'Ringweg 3', nummer: 'ST-HAUS-0007', art: 'Strom', stand: 88420.3, stand_am: '15.09.2026', geaendert_am: '15.09.2026, 09:45' },
|
||||
]
|
||||
|
||||
module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters }
|
||||
const buildings = [
|
||||
{ name: 'Hauptstraße 12', adresse: 'Hauptstraße 12', baujahr: 1968, stockwerke: 4, etagenhoehe: 2.65, wohneinheiten: 20, quadratmeter: 1570.0 },
|
||||
{ name: 'Ringweg 3', adresse: 'Ringweg 3', baujahr: 1955, stockwerke: 3, etagenhoehe: 2.80, wohneinheiten: 15, quadratmeter: 1110.0 },
|
||||
{ name: 'Am Park 5', adresse: 'Am Park 5', baujahr: 2019, stockwerke: 3, etagenhoehe: 2.60, wohneinheiten: 15, quadratmeter: 1110.0 },
|
||||
]
|
||||
|
||||
module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings }
|
||||
|
||||
+18
-1
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
const { pool } = require('./db')
|
||||
const { users, registrationCodes, tickets, documents, threads, announcements, meters } = require('./seed-data')
|
||||
const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } = require('./seed-data')
|
||||
const generated = require('../seed/generated.json')
|
||||
|
||||
async function seed() {
|
||||
@@ -124,3 +124,20 @@ async function seedeZaehler() {
|
||||
}
|
||||
|
||||
module.exports = { seed, seedeZaehler }
|
||||
|
||||
/** Haus-Demo-Daten: idempotent – füllt die buildings-Tabelle nur, wenn sie
|
||||
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.10). */
|
||||
async function seedeHaeuser() {
|
||||
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings')
|
||||
if (rows[0].n > 0) return false
|
||||
for (const h of buildings) {
|
||||
await pool.query(
|
||||
`INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[h.name, h.adresse, h.baujahr, h.stockwerke, h.etagenhoehe, h.wohneinheiten, h.quadratmeter],
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { seed, seedeZaehler, seedeHaeuser }
|
||||
|
||||
Reference in New Issue
Block a user