Files
Mieterportal/server/src/db.js
T

236 lines
9.2 KiB
JavaScript

/** PostgreSQL-Verbindung + Schema für das Mieterportal.
*
* Tabellen werden beim Start idempotent angelegt (CREATE TABLE IF NOT EXISTS),
* danach lädt seed.js die Demo-Daten, falls die Datenbank leer ist.
*/
const { Pool } = require('pg')
const pool = new Pool({
connectionString:
process.env.DATABASE_URL || 'postgres://mp:mp@localhost:5432/mieterportal',
max: 10,
})
const SCHEMA = `
CREATE TABLE IF NOT EXISTS units (
id TEXT PRIMARY KEY,
building TEXT NOT NULL,
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'))
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
month TEXT NOT NULL,
year INT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('bezahlt','offen','überfällig')),
paid_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_transactions_unit ON transactions(unit_id);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
priority TEXT NOT NULL CHECK (priority IN ('niedrig','mittel','hoch')),
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','in Bearbeitung','erledigt')),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tickets_unit ON tickets(unit_id);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
unit_id TEXT REFERENCES units(id), -- NULL = Hausdokument für alle
title TEXT NOT NULL,
category TEXT NOT NULL,
size TEXT NOT NULL,
date TEXT NOT NULL
);
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_name TEXT; -- Originalname beim Upload
ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_path TEXT; -- Dateiname im Upload-Verzeichnis
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
subject TEXT NOT NULL,
unread_by_admin BOOLEAN NOT NULL DEFAULT FALSE,
unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_threads_unit ON threads(unit_id);
-- Verknüpfung Unterhaltung ↔ Schadensmeldung (v1.19)
ALTER TABLE threads ADD COLUMN IF NOT EXISTS ticket_id TEXT REFERENCES tickets(id);
CREATE INDEX IF NOT EXISTS idx_threads_ticket ON threads(ticket_id);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
from_role TEXT NOT NULL CHECK (from_role IN ('tenant','admin')),
author TEXT NOT NULL,
text TEXT NOT NULL,
at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
CREATE TABLE IF NOT EXISTS announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
text TEXT NOT NULL,
date TEXT NOT NULL,
tag TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
benutzername TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
passwort_hash TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('verwaltung','mieter')),
email TEXT NOT NULL,
einheit_id TEXT REFERENCES units(id),
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_am TEXT NOT NULL,
letzter_login TEXT
);
-- Stammdaten zur Selbstpflege durch den Nutzer (v1.20)
ALTER TABLE users ADD COLUMN IF NOT EXISTS telefon TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS kontoinhaber TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS iban TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS bankname TEXT;
CREATE TABLE IF NOT EXISTS registration_codes (
code TEXT PRIMARY KEY,
einheit_id TEXT REFERENCES units(id),
erstellt_am TEXT NOT NULL,
verwendet_am TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS meters (
id TEXT PRIMARY KEY,
unit_id TEXT REFERENCES units(id), -- NULL = Haus-/Allgemeinzähler
building TEXT NOT NULL,
nummer TEXT NOT NULL, -- Zählernummer
art TEXT NOT NULL CHECK (art IN ('Strom','Wasser','Gas','Wärme','Sonstiges')),
stand NUMERIC(12,3) NOT NULL,
stand_am TEXT NOT NULL, -- Datum des abgelesenen Standes
geaendert_am TEXT NOT NULL -- letzte Pflege durch die Verwaltung
);
CREATE INDEX IF NOT EXISTS idx_meters_unit ON meters(unit_id);
-- Zählerverlauf (v1.25): jeder abgelesene Stand bleibt als Listeneintrag
-- bestehen; meters.stand ist jeweils der aktuellste Wert (Cache).
CREATE TABLE IF NOT EXISTS meter_readings (
id TEXT PRIMARY KEY,
seq BIGSERIAL, -- Einfügereihenfolge (verlässliche Sortierung)
meter_id TEXT NOT NULL REFERENCES meters(id) ON DELETE CASCADE,
stand NUMERIC(12,3) NOT NULL,
stand_am TEXT NOT NULL, -- ISO yyyy-mm-dd oder de-DE
erfasst_von TEXT NOT NULL DEFAULT '', -- Name des erfassenden Nutzers
quelle TEXT NOT NULL DEFAULT 'manuel',
CHECK (quelle IN ('erstbefund','manuel','mieter')),
anomalie BOOLEAN NOT NULL DEFAULT FALSE,
anomalie_grund TEXT, -- gesetzt bei Auffälligkeit
erstellt_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meter_readings_meter ON meter_readings(meter_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²
);
-- v1.26: Adresse in Einzelfelder aufgeteilt, Flächen aufgeteilt, optionale Lage
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS strasse TEXT NOT NULL DEFAULT '';
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS hausnummer TEXT NOT NULL DEFAULT '';
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS plz TEXT NOT NULL DEFAULT '';
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS ort TEXT NOT NULL DEFAULT '';
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS lage TEXT NOT NULL DEFAULT '';
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS wohnflaeche NUMERIC(9,1);
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS grundstuecksflaeche NUMERIC(9,1);
CREATE TABLE IF NOT EXISTS costs (
id TEXT PRIMARY KEY,
building TEXT NOT NULL REFERENCES buildings(name), -- Haus
unit_id TEXT REFERENCES units(id), -- NULL = hausweite Position
jahr INT NOT NULL,
kostenart TEXT NOT NULL,
betrag NUMERIC(10,2) NOT NULL,
umlegbar BOOLEAN NOT NULL DEFAULT TRUE, -- umlegbar / nicht umlegbar
notiz TEXT NOT NULL DEFAULT '',
erfasst_am TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_costs_building ON costs(building);
CREATE INDEX IF NOT EXISTS idx_costs_unit ON costs(unit_id);
-- FK hausweit: Umbenennung eines Hauses muss costs.building automatisch
-- mitziehen (sonst bricht jede Haus-Umbenennung mit FK-Verletzung ab,
-- sobald Kostenpositionen existieren). Constraint idempotent neu setzen.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'costs_building_fkey') THEN
ALTER TABLE costs DROP CONSTRAINT costs_building_fkey;
END IF;
ALTER TABLE costs ADD CONSTRAINT costs_building_fkey
FOREIGN KEY (building) REFERENCES buildings(name) ON UPDATE CASCADE;
END $$;
-- Gescannte Belege aus SparkReceipt (Cloud-Archiv), per Webhook importiert.
-- (quelle, externe_id) eindeutig → wiederholte Events aktualisieren statt zu duplizieren.
CREATE TABLE IF NOT EXISTS belege (
id TEXT PRIMARY KEY,
quelle TEXT NOT NULL DEFAULT 'sparkreceipt',
externe_id TEXT NOT NULL,
haendler TEXT NOT NULL DEFAULT '',
betrag NUMERIC(10,2),
waehrung TEXT NOT NULL DEFAULT 'EUR',
datum TEXT NOT NULL DEFAULT '',
jahr INT,
kategorie TEXT NOT NULL DEFAULT '',
notiz TEXT NOT NULL DEFAULT '',
datei_url TEXT NOT NULL DEFAULT '',
zugeordnet BOOLEAN NOT NULL DEFAULT FALSE,
erfasst_am TEXT NOT NULL,
UNIQUE (quelle, externe_id)
);
-- Auto-Zuordnung: primär Stromzählernummer, sekundär Adresse (v1.17)
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
`
async function init() {
await pool.query(SCHEMA)
}
/** Aufräumen abgelaufener Sessions (gelegentlich aufgerufen). */
async function sessionsAufraeumen() {
await pool.query('DELETE FROM sessions WHERE expires < now()')
}
module.exports = { pool, init, sessionsAufraeumen }