v1.35: Mietvertragsmodul (Entwurf-PDF, Versand, Unterschrift, Mieteranlage)
This commit is contained in:
@@ -227,6 +227,35 @@ ALTER TABLE belege ADD COLUMN IF NOT EXISTS unit_id TEXT REFERENCES units(id);
|
||||
ALTER TABLE belege ADD COLUMN IF NOT EXISTS gebaude TEXT;
|
||||
ALTER TABLE belege ADD COLUMN IF NOT EXISTS zuordnung TEXT; -- zaehlernummer | adresse | manuell
|
||||
ALTER TABLE belege ADD COLUMN IF NOT EXISTS rohtext TEXT; -- Volltext des Events für die Zuordnung
|
||||
|
||||
-- Portal-/Vermieterdaten für Mietvertrags-PDFs (v1.35, Schlüssel-Wert)
|
||||
CREATE TABLE IF NOT EXISTS einstellungen (
|
||||
schluessel TEXT PRIMARY KEY,
|
||||
wert TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
-- Mietverträge (v1.35): Entwurf → versendet → unterschrieben → aktiv.
|
||||
-- Dokument- und Benutzerbezüge bewusst ohne harten FK, damit das Löschen
|
||||
-- von Dokumenten/Benutzern nicht an der Vertragshistorie scheitert.
|
||||
CREATE TABLE IF NOT EXISTS mietvertraege (
|
||||
id TEXT PRIMARY KEY,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
mieter_name TEXT NOT NULL,
|
||||
mieter_email TEXT NOT NULL DEFAULT '',
|
||||
mieter_telefon TEXT NOT NULL DEFAULT '',
|
||||
beginn_am TEXT NOT NULL,
|
||||
ende_am TEXT NOT NULL DEFAULT '', -- '' = unbefristet
|
||||
kaltmiete NUMERIC(10,2) NOT NULL,
|
||||
nebenkosten NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
kaution NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||
CHECK (status IN ('entwurf','versendet','unterschrieben','aktiv')),
|
||||
dokument_id TEXT NOT NULL DEFAULT '', -- Entwurf als PDF (Dokumentenmodul)
|
||||
unterschriften_dokument_id TEXT NOT NULL DEFAULT '', -- unterschriebener Vertrag (Scan)
|
||||
benutzer_id TEXT NOT NULL DEFAULT '', -- angelegtes Mieterkonto
|
||||
erstellt_am TEXT NOT NULL,
|
||||
geaendert_am TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mietvertraege_unit ON mietvertraege(unit_id);
|
||||
`
|
||||
|
||||
async function init() {
|
||||
|
||||
+294
-1
@@ -15,6 +15,7 @@ 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 PORT = Number(process.env.PORT || 3000)
|
||||
@@ -172,6 +173,14 @@ const mapDocument = (d) => ({
|
||||
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,
|
||||
@@ -342,7 +351,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] =
|
||||
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs, belege, mietvertraege] =
|
||||
await Promise.all([
|
||||
pool.query('SELECT * FROM units ORDER BY id'),
|
||||
istAdmin
|
||||
@@ -379,6 +388,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM belege ORDER BY erfasst_am DESC')
|
||||
: { rows: [] },
|
||||
istAdmin
|
||||
? pool.query('SELECT * FROM mietvertraege ORDER BY erstellt_am DESC')
|
||||
: { rows: [] },
|
||||
])
|
||||
// Threads mit ihren Nachrichten zusammenführen
|
||||
const nachrichtenProThread = {}
|
||||
@@ -402,6 +414,8 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
||||
buildings: buildings.rows.map(mapBuilding),
|
||||
kosten: costs.rows.map(mapKosten),
|
||||
belege: belege.rows.map(mapBeleg),
|
||||
mietvertraege: mietvertraege.rows.map(mapMietvertrag),
|
||||
einstellungen: await vermieterDaten(pool),
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
@@ -802,6 +816,12 @@ 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(() => {})
|
||||
@@ -1386,6 +1406,7 @@ app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
|
||||
['threads', 'unit_id', 'Unterhaltungen'],
|
||||
['registration_codes', 'einheit_id', 'Registrierungscodes'],
|
||||
['belege', 'unit_id', 'Belege'],
|
||||
['mietvertraege', 'unit_id', 'Mietverträge'],
|
||||
]) {
|
||||
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}`)
|
||||
@@ -1705,6 +1726,278 @@ app.post('/api/abrechnungen', auth, adminOnly, async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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]
|
||||
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)
|
||||
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],
|
||||
)
|
||||
// Wohnung als vermietet führen
|
||||
await pool.query(
|
||||
`UPDATE units SET tenant = $1, status = 'vermietet' WHERE id = $2`,
|
||||
[vertrag.mieter_name, 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)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/** Mietvertrag als PDF (v1.35).
|
||||
*
|
||||
* Erzeugt einen rechtskonformen Mietvertrags-Entwurf (Wohnraummietvertrag,
|
||||
* Mustervorlage) aus den hinterlegten Vermieterdaten (Tabelle einstellungen),
|
||||
* den Mieterdaten des Vertrags und den Bestandsdaten (Haus/Wohnung).
|
||||
* Das PDF landet im Upload-Verzeichnis und wird als Dokument
|
||||
* („Mietvertrag") der Wohnung zugeordnet – erneutes Erzeugen ersetzt
|
||||
* Datei und Dokument.
|
||||
*
|
||||
* Hinweis: Es handelt sich um eine automatisch erzeugte Mustervorlage;
|
||||
* die Verwaltung prüft den Entwurf vor Versand/Unterschrift.
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const PDFDocument = require('pdfkit')
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads')
|
||||
const FARBE_DUNKEL = '#22333b'
|
||||
const FARBE_AKZENT = '#f59e0b'
|
||||
const FARBE_SOFT = '#64748b'
|
||||
|
||||
const eur = (n) =>
|
||||
Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||
const zahl = (n) =>
|
||||
Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 2 })
|
||||
const dDatum = () => new Date().toLocaleDateString('de-DE')
|
||||
|
||||
/** Liest die Vermieterdaten (Schlüssel-Wert) als Objekt. */
|
||||
async function vermieterDaten(pool) {
|
||||
const { rows } = await pool.query('SELECT schluessel, wert FROM einstellungen')
|
||||
const d = {}
|
||||
for (const r of rows) d[r.schluessel] = r.wert
|
||||
return {
|
||||
name: d.vermieter_name || '',
|
||||
strasse: d.vermieter_strasse || '',
|
||||
hausnummer: d.vermieter_hausnummer || '',
|
||||
plz: d.vermieter_plz || '',
|
||||
ort: d.vermieter_ort || '',
|
||||
telefon: d.vermieter_telefon || '',
|
||||
email: d.vermieter_email || '',
|
||||
bankname: d.vermieter_bankname || '',
|
||||
iban: d.vermieter_iban || '',
|
||||
}
|
||||
}
|
||||
|
||||
const vermieterAdresse = (v) =>
|
||||
[v.strasse && `${v.strasse} ${v.hausnummer}`, v.plz && `${v.plz} ${v.ort}`].filter(Boolean).join(', ')
|
||||
|
||||
/** Zeichnet den Vertrag und liefert den Dateinamen (im UPLOAD_DIR). */
|
||||
async function bauePdf(vertrag, einheit, haus, vermieter) {
|
||||
const dateiName = `mv-${vertrag.id}.pdf`
|
||||
const vollerPfad = path.join(UPLOAD_DIR, dateiName)
|
||||
const anschrift = `${haus.strasse} ${haus.hausnummer}, ${haus.plz} ${haus.ort}`
|
||||
const warmmiete = Number(vertrag.kaltmiete) + Number(vertrag.nebenkosten)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50, info: {
|
||||
Title: `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number}`,
|
||||
Author: vermieter.name || 'Hinterstein Immobilien',
|
||||
} })
|
||||
const strom = fs.createWriteStream(vollerPfad)
|
||||
strom.on('finish', resolve)
|
||||
strom.on('error', reject)
|
||||
doc.pipe(strom)
|
||||
|
||||
const breite = doc.page.width - 100
|
||||
let y = doc.y
|
||||
|
||||
// ── Briefkopf: Vermieter links, Mieter rechts ──
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL)
|
||||
doc.text(vermieter.name || 'Vermieter', 50, y)
|
||||
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
|
||||
const vermieterZeilen = [
|
||||
vermieterAdresse(vermieter),
|
||||
vermieter.telefon, vermieter.email,
|
||||
].filter(Boolean)
|
||||
doc.text(vermieterZeilen.join('\n') || 'Vermieterdaten im Portal hinterlegen', 50, y + 16, { width: 240 })
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor(FARBE_DUNKEL)
|
||||
doc.text(vertrag.mieter_name, 310, y, { width: 235 })
|
||||
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
|
||||
doc.text('Mieter/in', 310, y + 16, { width: 235 })
|
||||
doc.moveTo(50, y + 70).lineTo(50 + breite, y + 70).lineWidth(1.5).strokeColor(FARBE_AKZENT).stroke()
|
||||
y += 86
|
||||
|
||||
// ── Titel ──
|
||||
doc.font('Helvetica-Bold').fontSize(15).fillColor(FARBE_DUNKEL)
|
||||
doc.text('Mietvertrag über Wohnraum', 50, y, { width: breite, align: 'center' })
|
||||
y += 20
|
||||
doc.font('Helvetica').fontSize(9).fillColor(FARBE_SOFT)
|
||||
doc.text(`Entwurf vom ${dDatum()} – erzeugt aus dem Mieterportal`, 50, y, { width: breite, align: 'center' })
|
||||
y += 26
|
||||
|
||||
const absatz = (titel, zeilen) => {
|
||||
if (y > 700) { doc.addPage(); y = 60 }
|
||||
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL)
|
||||
doc.text(titel, 50, y)
|
||||
y += 16
|
||||
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
|
||||
for (const zeile of zeilen) {
|
||||
const h = doc.heightOfString(zeile, { width: breite })
|
||||
if (y + h > 780) { doc.addPage(); y = 60 }
|
||||
doc.text(zeile, 50, y, { width: breite, lineGap: 2 })
|
||||
y += h + 7
|
||||
}
|
||||
y += 6
|
||||
}
|
||||
|
||||
// ── Vertragsparteien & Mietobjekt ──
|
||||
absatz('§ 1 Vertragsparteien und Mietobjekt', [
|
||||
`Zwischen dem Vermieter, ${vermieter.name || '…'} (${vermieterAdresse(vermieter) || 'Adresse siehe Briefkopf'}), `
|
||||
+ `und dem/der Mieter/in, ${vertrag.mieter_name}, wird folgender Mietvertrag geschlossen.`,
|
||||
`Dem Mieter wird die Wohnung Nr. ${einheit.number} im Haus ${anschrift} `
|
||||
+ `${haus.lage ? `(${haus.lage}) ` : ''}zum Wohnen überlassen. `
|
||||
+ `Die Wohnung liegt in Etage ${einheit.floor}, die Wohnfläche beträgt ca. ${zahl(einheit.size)} m².`
|
||||
+ (haus.heizungsart ? ` Die Wohnung wird mit ${haus.heizungsart} beheizt.` : ''),
|
||||
`Zum Mietobjekt gehören die in der Wohnung vorhandenen fest installierten Einrichtungen.`
|
||||
])
|
||||
|
||||
// ── Mietzeit ──
|
||||
absatz('§ 2 Mietzeit', [
|
||||
`Das Mietverhältnis beginnt am ${vertrag.beginn_am}. `
|
||||
+ (vertrag.ende_am
|
||||
? `Es wird auf den ${vertrag.ende_am} befristet geschlossen.`
|
||||
: 'Es wird auf unbestimmte Zeit geschlossen (unbefristetes Mietverhältnis).'),
|
||||
`Die Kündigung richtet sich nach den gesetzlichen Vorschriften (§§ 573, 573c BGB).`
|
||||
])
|
||||
|
||||
// ── Miete ──
|
||||
absatz('§ 3 Miete und Nebenkosten', [
|
||||
`Die monatliche Kaltmiete beträgt ${eur(vertrag.kaltmiete)}.`,
|
||||
`Als Vorauszahlung auf die Betriebskosten werden monatlich zusätzlich ${eur(vertrag.nebenkosten)} (Nebenkostenvorauszahlung) gezahlt. `
|
||||
+ `Die jährliche Abrechnung der Betriebskosten erfolgt nach tatsächlichem Verbrauch bzw. nach der in der Betriebskostenverordnung geregelten Verteilung.`,
|
||||
`Die Warmmiete (Kaltmiete zzgl. Nebenkostenvorauszahlung) beträgt somit ${eur(warmmiete)} monatlich. `
|
||||
+ `Sie ist jeweils bis zum dritten Werktag des Monats im Voraus zu entrichten.`,
|
||||
`Die Miete ist auf das Konto ${vermieter.bankname || 'des Vermieters'} (IBAN: ${vermieter.iban || '–'}) zu überweisen. `
|
||||
+ 'Ein Zurückbehaltungsrecht des Mieters ist ausgeschlossen.'
|
||||
])
|
||||
|
||||
// ── Kaution ──
|
||||
absatz('§ 4 Kaution', [
|
||||
`Der Mieter leistet bei Vertragsschluss eine Kaution in Höhe von ${eur(vertrag.kaution)} `
|
||||
+ '(maximal drei Nettokaltmieten, § 551 BGB).',
|
||||
'Die Kaution kann in drei gleichen monatlichen Raten geleistet werden. Sie wird auf einem Mietkonto mit Zinssatz '
|
||||
+ 'für Spareinlagen mit dreimonatiger Kündigungsfrist angelegt und nach Beendigung des Mietverhältnisses '
|
||||
+ 'zuzüglich der Zinsen zurückgegeben.'
|
||||
])
|
||||
|
||||
// ── Pflichten ──
|
||||
absatz('§ 5 Gebrauch, Instandhaltung und Schönheitsreparaturen', [
|
||||
'Die Wohnung ist nur zum Wohnen bestimmt. Die Überlassung an Dritte (Untervermietung) bedarf der vorherigen schriftlichen Zustimmung des Vermieters.',
|
||||
'Der Mieter hat die Wohnung ordnungsgemäß zu pflegen, kleine Instandhaltungen (z. B. Batterien in Rauchwarnmeldern) selbst zu übernehmen und Schäden unverzüglich dem Vermieter zu melden.',
|
||||
'Schönheitsreparaturen obliegen dem Mieter im üblichen Umfang (§ 28 Abs. 4 BBauG, § 21 BGB); abweichende Vereinbarungen bleiben vorbehalten.',
|
||||
'Rauchwarnmelder werden vom Vermieter bereitgestellt; der Mieter übernimmt die regelmäßige Prüfung (§§ 17, 18 BaulVO NRW).'
|
||||
])
|
||||
|
||||
// ── Schluss ──
|
||||
absatz('§ 6 Schlussbestimmungen', [
|
||||
'Änderungen und Ergänzungen dieses Vertrags bedürfen zu ihrer Wirksamkeit der Schriftform; dies gilt auch für die Aufhebung dieser Schriftformklausel.',
|
||||
'Sollte eine Bestimmung dieses Vertrags unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.',
|
||||
'Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist der Sitz des Vermieters, soweit dem nicht zwingend etwas entgegensteht.'
|
||||
])
|
||||
|
||||
// ── Unterschriften ──
|
||||
if (y > 620) { doc.addPage(); y = 60 }
|
||||
y += 10
|
||||
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(FARBE_DUNKEL)
|
||||
doc.text('Unterschriften', 50, y)
|
||||
y += 18
|
||||
doc.font('Helvetica').fontSize(9.5).fillColor('#0f172a')
|
||||
doc.text(`Ort, Datum`, 50, y)
|
||||
doc.text(`Ort, Datum`, 310, y)
|
||||
y += 34
|
||||
doc.moveTo(50, y).lineTo(270, y).lineWidth(0.75).strokeColor('#94a3b8').stroke()
|
||||
doc.moveTo(310, y).lineTo(530, y).lineWidth(0.75).strokeColor('#94a3b8').stroke()
|
||||
y += 6
|
||||
doc.font('Helvetica').fontSize(8.5).fillColor(FARBE_SOFT)
|
||||
doc.text(vermieter.name || 'Vermieter', 50, y, { width: 220 })
|
||||
doc.text(vertrag.mieter_name, 310, y, { width: 220 })
|
||||
y += 22
|
||||
|
||||
// ── Fußzeile ──
|
||||
doc.font('Helvetica').fontSize(8).fillColor(FARBE_SOFT)
|
||||
doc.text(
|
||||
'Mustervorlage, automatisch aus dem Mieterportal erzeugt. Dieser Entwurf ersetzt keine Rechtsberatung – '
|
||||
+ 'die Verwaltung prüft den Vertrag vor Versand und Unterschrift.',
|
||||
50, Math.max(y + 8, 770), { width: breite },
|
||||
)
|
||||
|
||||
doc.end()
|
||||
})
|
||||
return dateiName
|
||||
}
|
||||
|
||||
/** Erzeugt den Vertrags-Entwurf: PDF + Dokumenten-Eintrag (ersetzt vorhandene). */
|
||||
async function erzeugeVertragsPdf(pool, vertrag) {
|
||||
const { rows: einheiten } = await pool.query('SELECT * FROM units WHERE id = $1', [vertrag.unit_id])
|
||||
if (!einheiten.length) throw Object.assign(new Error('Wohnung nicht gefunden.'), { status: 404 })
|
||||
const einheit = einheiten[0]
|
||||
const { rows: haeuser } = await pool.query('SELECT * FROM buildings WHERE name = $1', [einheit.building])
|
||||
if (!haeuser.length) throw Object.assign(new Error('Haus nicht gefunden.'), { status: 404 })
|
||||
const haus = haeuser[0]
|
||||
const vermieter = await vermieterDaten(pool)
|
||||
|
||||
const dateiName = await bauePdf(vertrag, einheit, haus, vermieter)
|
||||
const groesseBytes = fs.statSync(path.join(UPLOAD_DIR, dateiName)).size
|
||||
const groesse = groesseBytes > 1024
|
||||
? `${zahl(groesseBytes / 1024)} KB`
|
||||
: `${groesseBytes} B`
|
||||
const titel = `Mietvertrag Entwurf – ${einheit.building}, Whg. ${einheit.number} – ${vertrag.mieter_name}`
|
||||
|
||||
const { rows: vorhanden } = await pool.query(
|
||||
'SELECT * FROM documents WHERE id = $1', [vertrag.dokument_id || ''],
|
||||
)
|
||||
let dokument
|
||||
if (vorhanden.length) {
|
||||
const alt = vorhanden[0].file_path
|
||||
if (alt && alt !== dateiName) {
|
||||
await fs.promises.unlink(path.join(UPLOAD_DIR, alt)).catch(() => {})
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE documents SET title = $1, file_name = $2, file_path = $3, size = $4, date = $5, category = 'Mietvertrag', unit_id = $6
|
||||
WHERE id = $7 RETURNING *`,
|
||||
[titel, titel, dateiName, groesse, dDatum(), einheit.id, vorhanden[0].id],
|
||||
)
|
||||
dokument = rows[0]
|
||||
} else {
|
||||
const dokumentId = `d-mv-${vertrag.id}`
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO documents (id, unit_id, title, category, size, date, file_name, file_path)
|
||||
VALUES ($1,$2,$3,'Mietvertrag',$4,$5,$6,$7) RETURNING *`,
|
||||
[dokumentId, einheit.id, titel, groesse, dDatum(), titel, dateiName],
|
||||
)
|
||||
dokument = rows[0]
|
||||
}
|
||||
await pool.query('UPDATE mietvertraege SET dokument_id = $1, geaendert_am = $2 WHERE id = $3',
|
||||
[dokument.id, dDatum(), vertrag.id])
|
||||
return dokument
|
||||
}
|
||||
|
||||
module.exports = { erzeugeVertragsPdf, vermieterDaten, vermieterAdresse }
|
||||
Reference in New Issue
Block a user