Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
@@ -0,0 +1,469 @@
-- =============================================================================
-- Phase 2: Tabellen für Wartung und Prüfung.
--
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
--
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
-- die neuen Tabellen stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
-- CreateEnum
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
-- CreateEnum
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
-- CreateEnum
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
-- CreateEnum
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
-- CreateEnum
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
-- CreateEnum
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
-- CreateEnum
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
-- CreateEnum
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
-- CreateTable
CREATE TABLE "wartungsvorlage" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"geraetetypId" UUID,
"intervallMonate" INTEGER NOT NULL,
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
"pruefvorschrift" VARCHAR(200),
"standardDauerMinuten" INTEGER,
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
"automatisch" BOOLEAN NOT NULL DEFAULT true,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "checklistenpunkt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorlageId" UUID NOT NULL,
"text" VARCHAR(300) NOT NULL,
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
"pflicht" BOOLEAN NOT NULL DEFAULT true,
"sollwert" VARCHAR(120),
"einheit" VARCHAR(20),
"minWert" DECIMAL(12,3),
"maxWert" DECIMAL(12,3),
"hinweis" VARCHAR(500),
"sortierung" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wartungstermin" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"vorlageId" UUID,
"faelligAm" DATE NOT NULL,
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
"zugewiesenAnId" UUID,
"bemerkung" VARCHAR(1000),
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"terminId" UUID,
"vorlageId" UUID,
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
"prueferId" UUID,
"prueferName" VARCHAR(200),
"durchgefuehrtAm" DATE NOT NULL,
"begonnenUm" TIMESTAMPTZ(3),
"dauerMinuten" INTEGER,
"ergebnis" "PruefErgebnis",
"externeFirma" VARCHAR(200),
"externeProtokollDateiId" UUID,
"kosten" DECIMAL(10,2),
"naechstePruefungAm" DATE,
"protokollDateiId" UUID,
"protokollNummer" VARCHAR(40),
"abgeschlossenAm" TIMESTAMPTZ(3),
"ersetztPruefungId" UUID,
"korrekturGrund" VARCHAR(500),
"bemerkung" VARCHAR(2000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefergebnis" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"punktId" UUID NOT NULL,
"jaNein" BOOLEAN,
"zahlwert" DECIMAL(12,3),
"text" VARCHAR(1000),
"fotoDateiId" UUID,
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "signatur" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
"personId" UUID,
"name" VARCHAR(200) NOT NULL,
"bildDateiId" UUID,
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mangel" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"pruefungId" UUID,
"melderId" UUID,
"melderName" VARCHAR(200),
"beschreibung" VARCHAR(2000) NOT NULL,
"fotoDateiId" UUID,
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
"zugewiesenAnId" UUID,
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"behobenAm" TIMESTAMPTZ(3),
"behobenVonId" UUID,
"behebung" VARCHAR(2000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"hersteller" VARCHAR(160) NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"referenz" VARCHAR(80),
"beschreibung" VARCHAR(4000),
"massnahme" VARCHAR(2000),
"veroeffentlichtAm" DATE,
"quelle" VARCHAR(500),
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_geraetetyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetetypId" UUID,
"modell" VARCHAR(160),
"seriennummerVon" VARCHAR(120),
"seriennummerBis" VARCHAR(120),
"herstellungVon" DATE,
"herstellungBis" DATE,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_betroffenheit" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erledigtAm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "erinnerung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"terminId" UUID NOT NULL,
"stufe" "ErinnerungStufe" NOT NULL,
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
"meldung" VARCHAR(500),
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
-- CreateIndex
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
-- CreateIndex
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
-- CreateIndex
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
-- CreateIndex
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
-- CreateIndex
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
-- CreateIndex
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
-- CreateIndex
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
-- =============================================================================
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
--
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
--
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
-- für die Tabellen aus Phase 0 und 1.
--
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
-- der Datenbank, nicht nur in lib/ und server/.
--
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
-- Rückrufe.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'wartungsvorlage',
'checklistenpunkt',
'wartungstermin',
'pruefung',
'pruefergebnis',
'signatur',
'mangel',
'rueckruf',
'rueckruf_geraetetyp',
'rueckruf_betroffenheit',
'erinnerung'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Unveränderbarkeit abgeschlossener Prüfungen
--
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
--
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
--
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
vergleich "pruefung"%ROWTYPE;
BEGIN
-- Entwürfe sind frei änderbar.
IF OLD."status" = 'ENTWURF' THEN
RETURN NEW;
END IF;
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
vergleich := NEW;
vergleich."geaendertAm" := OLD."geaendertAm";
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
vergleich."status" := OLD."status";
END IF;
IF OLD."protokollDateiId" IS NULL THEN
vergleich."protokollDateiId" := OLD."protokollDateiId";
END IF;
IF OLD."protokollNummer" IS NULL THEN
vergleich."protokollNummer" := OLD."protokollNummer";
END IF;
IF vergleich IS DISTINCT FROM OLD THEN
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD."status" = 'ENTWURF' THEN
RETURN OLD;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
'(nur im Aufbewahrungslauf).',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefung_abschluss_schuetzen
BEFORE UPDATE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
CREATE TRIGGER pruefung_loeschschutz
BEFORE DELETE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
-- -----------------------------------------------------------------------------
-- Dasselbe für die Bestandteile eines Protokolls.
--
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
betroffene_pruefung uuid;
zustand "PruefungStatus";
BEGIN
IF TG_OP = 'DELETE' THEN
betroffene_pruefung := OLD."pruefungId";
ELSE
betroffene_pruefung := NEW."pruefungId";
END IF;
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
betroffene_pruefung, TG_OP, TG_TABLE_NAME
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefergebnis_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
CREATE TRIGGER signatur_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
-- -----------------------------------------------------------------------------
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
-- -----------------------------------------------------------------------------
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
);
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
-- erzeugen.
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
);
-- -----------------------------------------------------------------------------
-- Indizes
--
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
-- -----------------------------------------------------------------------------
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"