diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 15c20dd0..d95fb7dc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -199,10 +199,6 @@ body { font: inherit; } -[data-testid="planungsfenster-liste"] { - padding-inline-start: 1.25rem; -} - /* * Status nie NUR über Farbe (EYT-41): Text + Symbol tragen die * Information, Farbe verstärkt sie lediglich. @@ -697,3 +693,304 @@ body { display: grid; align-content: start; } + +/* ============================================================ + * EYT-147 — Dispositionswerkbank Slice 1: die Woche ist das Produkt + * + * Nur Anordnung und Dichte; jede Farbe kommt aus den Rollen von + * `@easytree/ui`. Kein neues Farbliteral (basisdesign-tokens.test.ts + * verbietet es), keine Animation. + * ============================================================ */ + +/* + * Die Planungsflaeche bekommt die Breite, die eine Wochenachse braucht — + * NUR sie: Start- und Kostenflaeche behalten die zentrierte 60rem-Spalte, + * und die vermessene Geometrie der Startseite (shell-smoke) bleibt + * unberuehrt, weil `.werkbank` dort nicht vorkommt. + */ +.eyt-app-shell__main:has(.werkbank) { + max-width: 110rem; +} + +/* + * Kopf und Wochennavigation bilden ab Werkbankbreite EINE Toolbar-Zeile: + * links die Antwort „was plane ich" (h1 + Zeitraum), rechts die Bedienung. + * Die DOM-Reihenfolge Kopf → Navigation → Flaeche bleibt unveraendert — + * hier wird nur angeordnet. + */ +@media (min-width: 64rem) { + .werkbank { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + column-gap: var(--space); + } + + .werkbank__kopf { + grid-column: 1; + } + + .werkbank > .eyt-date-range { + grid-column: 2; + } + + .werkbank__flaeche { + grid-column: 1 / -1; + } + + /* In der Toolbar stehen Woche, Zeitraum und Wege in einer Zeile. */ + .werkbank .eyt-date-range__range, + .werkbank .eyt-date-range__detail { + flex-basis: auto; + } +} + +/* Statuszeile der Wochenflaeche: links der Gegenstand, rechts der Stand. */ +.werkbank-fenster__status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem var(--space); +} + +.werkbank-fenster__titel h2 { + margin: 0; + font-size: 1.0625rem; +} + +.werkbank-fenster__zone, +.werkbank-fenster__version { + margin: 0; + font-size: 0.8125rem; + color: var(--eyt-text-secondary); +} + +.werkbank-fenster__stand { + display: grid; + gap: 0.125rem; + justify-items: end; + text-align: right; +} + +.werkbank-fenster__stand > p { + margin: 0; +} + +/* Aktionszeile: Publish-Baustein (mit seinen Meldungen) und der Ausloeser + des Inspectors. Genau EINE der beiden Schaltflaechen ist primaer. */ +.werkbank-fenster__aktionen { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space); + margin-block: 0.75rem; +} + +.werkbank-fenster__aktionen .eyt-planung-publish { + flex: 1 1 24rem; + min-width: 0; +} + +.eyt-planung-publish__grenze { + margin: 0.25rem 0; + font-size: 0.8125rem; + color: var(--eyt-text-secondary); + max-width: 60rem; +} + +/* Flaeche: Wochenraster, daneben — wenn offen — der Inspector. */ +.werkbank-fenster__flaeche { + display: grid; + gap: var(--space); + align-items: start; +} + +@media (min-width: 64rem) { + .werkbank-fenster[data-inspector="offen"] .werkbank-fenster__flaeche { + grid-template-columns: minmax(0, 1fr) 22rem; + } +} + +/* + * Das Wochenraster: sieben Tage als raeumliche Achse. Unterhalb der + * Werkbankbreite (u. a. 200-%-Zoom-Aequivalent 720 px) stapeln die Tage + * untereinander — kein horizontales Seitenscrollen. + */ +.wochenraster { + display: grid; + gap: 1px; + border: 1px solid var(--eyt-border-default); + border-radius: var(--radius); + background: var(--eyt-border-default); + overflow: hidden; +} + +@media (min-width: 64rem) { + .wochenraster { + grid-template-columns: repeat(7, minmax(0, 1fr)); + } +} + +.wochenraster__tag { + background: var(--eyt-bg-canvas); + padding: 0.5rem; + min-height: 8rem; + display: grid; + grid-template-rows: auto 1fr; + gap: 0.375rem; + min-width: 0; +} + +.wochenraster__tagkopf { + margin: 0; + font-size: 0.8125rem; + font-weight: 650; +} + +.wochenraster__tagdatum { + font-weight: 400; + color: var(--eyt-text-secondary); + font-variant-numeric: tabular-nums; +} + +.wochenraster__frei { + margin: 0; + color: var(--eyt-text-secondary); + text-align: center; + align-self: center; +} + +.wochenraster__einsaetze { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.375rem; + align-content: start; +} + +.wochenraster__ausserhalb { + grid-column: 1 / -1; + background: var(--eyt-state-danger-bg); + padding: 0.5rem; +} + +.wochenraster__ausserhalb > h3 { + margin: 0 0 0.375rem; + font-size: 0.8125rem; + color: var(--eyt-state-danger-text); +} + +/* Einsatzkarte: Zeit, Baustelle, Person — kompakt, Zustand ueber Form. */ +.einsatzkarte { + background: var(--eyt-bg-surface); + border: 1px solid var(--eyt-border-default); + border-radius: calc(var(--radius) - 4px); + padding: 0.5rem 0.625rem; + display: grid; + gap: 0.125rem; + min-width: 0; + overflow-wrap: anywhere; +} + +.einsatzkarte__zeit { + font-size: 0.8125rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.einsatzkarte__baustelle { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.einsatzkarte__person { + font-size: 0.8125rem; + color: var(--eyt-text-secondary); +} + +/* Planstand als Form, nicht nur als Farbe: Entwurfskarten gestrichelt, + veroeffentlichte mit durchgehender linker Kante (Basisdesign §3.1). */ +.werkbank-fenster[data-stand="entwurf"] .einsatzkarte, +.werkbank-fenster[data-stand="entwurf-ueber-veroeffentlicht"] .einsatzkarte { + border-style: dashed; +} + +.werkbank-fenster[data-stand="veroeffentlicht"] .einsatzkarte { + border-inline-start: 3px solid var(--eyt-state-published-text); +} + +/* Der nichtmodale Erstellungs-Inspector. */ +.werkbank-inspector { + border: 1px solid var(--eyt-border-default); + border-radius: var(--radius); + background: var(--eyt-bg-surface); + padding: var(--space); +} + +.werkbank-inspector__kopf { + display: flex; + justify-content: flex-end; + margin-block-end: 0.25rem; +} + +/* + * Das Einsatzformular im Inspector traegt das Basisdesign v2 (PO-Review- + * Reparatur EYT-147): beschriftete Felder statt nackter Browser-Controls. + * Reine Darstellung — native Semantik, Testanker und die gesamte + * Validierungs-/Zeitzonenlogik liegen unveraendert in + * `planning-assignment-form.tsx`; jede Farbe ist eine Tokenrolle. + */ +.werkbank-inspector h3 { + margin: 0 0 0.75rem; + font-size: 0.9375rem; +} + +.einsatzformular { + gap: 0.75rem; +} + +.einsatzformular__feld { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.einsatzformular__feld > label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--eyt-text-secondary); +} + +.einsatzformular :is(select, input) { + padding: 0.5rem 0.625rem; + border: 1px solid var(--eyt-border-default); + border-radius: calc(var(--radius) - 4px); + background: var(--eyt-bg-canvas); + color: var(--eyt-text-primary); +} + +/* Beginn und Ende sind EIN Zeitraum — sie stehen nebeneinander. */ +.einsatzformular__zeiten { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.einsatzformular__meldung { + margin: 0.75rem 0 0; + padding: 0.5rem 0.625rem; + border-radius: calc(var(--radius) - 4px); + font-size: 0.875rem; +} + +.einsatzformular__meldung[data-state="erfolg"] { + background: var(--eyt-state-published-bg); + color: var(--eyt-state-published-text); +} + +.einsatzformular__meldung[data-state="fehler"] { + background: var(--eyt-state-danger-bg); + color: var(--eyt-state-danger-text); +} diff --git a/apps/web/components/planning-assignment-form.tsx b/apps/web/components/planning-assignment-form.tsx index 47929748..df9db5fb 100644 --- a/apps/web/components/planning-assignment-form.tsx +++ b/apps/web/components/planning-assignment-form.tsx @@ -250,69 +250,86 @@ export function AssignmentForm({ window: fenster, onSubmit }: AssignmentFormProp const feld = (name: string): string => `${idPrefix}-${name}`; return ( -
+ // Die Klassen sind reine Darstellung (PO-Review-Reparatur EYT-147): + // native Semantik, `label`-Zuordnung, Testanker und die gesamte + // Validierungs- und Zeitzonenlogik bleiben unveraendert — gestylt wird + // ueber `globals.css` mit den Basisdesign-v2-Tokens. +

Einsatz planen

- - - - - - - - aendern("datum")(e.target.value)} - /> - - - aendern("beginn")(e.target.value)} - /> - - - aendern("ende")(e.target.value)} - /> +
+ + +
+ +
+ + +
+ +
+ + aendern("datum")(e.target.value)} + /> +
+ +
+
+ + aendern("beginn")(e.target.value)} + /> +
+ +
+ + aendern("ende")(e.target.value)} + /> +
+
+ ) : ( + Einsatz anlegen + )} + {fenster.assignments.length === 0 ? ( - ) : ( -
    - {fenster.assignments.map((assignment) => ( -
  • - {/* Namen statt Uuids: eine Planerin erkennt "Anna Berg auf - Baustelle Nord", nicht 22222222-…. Die Ids bleiben als - data-Attribute im Markup, weil AK9 den Id-Vergleich zwischen - Planer- und Mitarbeitersicht verlangt — der braucht die Id - selbst, nicht ihre Darstellung. */} - {anzeigename(fenster.resources.employees, assignment.employeeId)} - {" auf "} - {anzeigename(fenster.resources.worksites, assignment.worksiteId)} - {": "} - {assignment.interval.startUtc} – {assignment.interval.endUtc} -
  • - ))} -
- )} + ) : null} + +
+ {raster.art === "raster" ? ( +
+ {raster.tage.map((tag) => ( +
+

+ {tag.wochentagsText}{" "} + {tag.datumsText} +

+ {tag.einsaetze.length === 0 ? ( + // Nur Zierde fuer Sehende: die Abwesenheit einer Liste sagt + // dem Screenreader dasselbe, ein zweiter Text waere Rauschen. + + ) : ( +
    + {tag.einsaetze.map((einsatz) => ( + + ))} +
+ )} +
+ ))} + {raster.ausserhalb.length > 0 ? ( + // Antworten, die der Server so eigentlich nicht liefern kann — + // aber „kann nicht sein" ist kein Renderpfad: sichtbar statt + // verschluckt (`lib/wochenraster.ts`). +
+

Außerhalb dieser Woche

+
    + {raster.ausserhalb.map((einsatz) => ( + + ))} +
+
+ ) : null} +
+ ) : ( + // Zone oder Woche unbestimmbar: die flache Liste ist die ehrliche + // Rueckfallebene — alle Daten sichtbar, nichts geraten. + <> + + {raster.grund === "zone-unbekannt" + ? `Die Zeitzone „${fenster.timeZone}“ ist dieser Laufzeit unbekannt; die Einsätze stehen ungeordnet untereinander.` + : `Der Wochenschlüssel „${fenster.weekKey}“ ist nicht lesbar; die Einsätze stehen ungeordnet untereinander.`} + + {fenster.assignments.length > 0 ? ( +
    + {fenster.assignments.map((einsatz) => ( + + ))} +
+ ) : null} + + )} - + {inspectorOffen ? ( +
{ + // Nichtmodal, aber mit Rueckweg: Escape schliesst und stellt + // den Fokus auf den Ausloeser zurueck. Kein Fokus-Trap — Tab + // verlaesst den Inspector wie jeden anderen Seitenbereich. + if (ereignis.key === "Escape") { + ereignis.stopPropagation(); + schliessen(); + } + }} + > +
+ +
+ +
+ ) : null} +
); } diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts index 36101f5c..1d6b27a0 100644 --- a/apps/web/e2e/auth-journey/journey.pwtest.ts +++ b/apps/web/e2e/auth-journey/journey.pwtest.ts @@ -1259,6 +1259,87 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ schritte["9c1_werkbank_breiten"] = { breiten: werkbankBreiten.map((b) => b.width) }; }); + // --------------------------------------------------------------------- + // 9c1b — EYT-147: ein Einsatz entsteht ueber den Inspector der Werkbank + // --------------------------------------------------------------------- + // Der EINZIGE UI-Schreibnachweis dieser Reise mit echter Identitaet: der + // read-through-Harness ersetzt `REQUEST_IDENTITY` und kann ueber die + // Identitaet nichts sagen. Bewusst in der EIGENEN Woche 2026-W34: W32 + // tragen die abgenommenen EYT-144-Kostenzahlen, W33 der Baustellenfilter, + // W35–W37 die Angriffe — ein zusaetzlicher Einsatz dort veraenderte + // abgenommene Summen. Der Server legt fuer die versionslose W34 selbst + // einen Entwurf an (planning-write.repository.ts, „holen oder anlegen"). + // 18.08. 07:00–09:00 Europe/Berlin = 05:00–07:00Z, Dienstag der W34. + await test.step("9c1b — EYT-147: Einsatz ueber den Inspector anlegen", async () => { + // Werkbankbreite fuer die Bild-Evidenz — die Dateinamen sagen 1440, also + // wird 1440 gemessen, nicht die Standardbreite des Laufs. + const breiteVorher = page.viewportSize(); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/planung?weekKey=2026-W34"); + const ausloeser = page.getByTestId("werkbank-einsatz-anlegen"); + await expect(ausloeser).toBeVisible(); + // Vor dem Oeffnen ist das Formular nicht im Baum — der Inspector ist der + // Erstellungskontext, kein Dauerformular. + await expect(page.getByTestId("einsatzformular")).toHaveCount(0); + await ausloeser.click(); + await expect(page.getByTestId("einsatzformular")).toBeVisible(); + await page.screenshot({ + path: join(ARTEFAKTE, "12-werkbank-inspector-1440.png"), + fullPage: true, + }); + + await page.getByTestId("feld-employee").selectOption("00000000-0000-4000-8000-00000000e211"); + await page.getByTestId("feld-worksite").selectOption("00000000-0000-4000-8000-00000000e241"); + await page.getByTestId("feld-datum").fill("2026-08-18"); + await page.getByTestId("feld-beginn").fill("07:00"); + await page.getByTestId("feld-ende").fill("09:00"); + const [schreibAntwort] = await Promise.all([ + page.waitForResponse( + (antwort) => + antwort.url().includes("/planung/einsaetze") && antwort.request().method() === "POST", + ), + page.getByTestId("einsatz-speichern").click(), + ]); + expect(schreibAntwort.status()).toBe(201); + const angelegt = (await schreibAntwort.json()) as { id: string }; + expect(angelegt.id).toMatch(/^[0-9a-f-]{36}$/); + + // Sichtbar wird der BESTAETIGTE Serverstand (Read-through), und die Karte + // steht am Kalendertag ihres Beginns — Dienstag, 18.08. + const karte = page.locator(`[data-assignment-id="${angelegt.id}"]`); + await expect(karte).toBeVisible(); + await expect(page.locator('[data-tag="2026-08-18"] [data-assignment-id]')).toHaveCount(1); + await page.screenshot({ + path: join(ARTEFAKTE, "13-werkbank-einsatz-serverbestaetigt.png"), + fullPage: true, + }); + + // Reload: dieselbe serverseitige Id, und die vorher versionslose Woche + // ist jetzt eindeutig als ENTWURF erkennbar. + await page.reload(); + await expect(page.locator(`[data-assignment-id="${angelegt.id}"]`)).toBeVisible(); + await expect(page.getByTestId("planungsfenster-stand")).toHaveAttribute( + "data-stand", + "entwurf", + ); + + // Zurueck in die Publish-Woche: 9d klickt das NAECHSTE + // `planung-veroeffentlichen` — bliebe die Seite auf W34 stehen, würde + // dieser Klick den frisch entstandenen W34-Entwurf veroeffentlichen und + // der Vergleich mit `entwurfsVersionId` (W32) ginge zu Recht rot + // (gemessen im ersten Lauf dieses Schritts). + await page.goto(`/planung?weekKey=${PLANWOCHE}`); + await expect(page.getByTestId("planungsfenster-stand")).toHaveAttribute( + "data-stand", + "entwurf", + ); + if (breiteVorher !== null) { + await page.setViewportSize(breiteVorher); + } + + schritte["9c1b_eyt147_inspector"] = { assignmentId: angelegt.id, woche: "2026-W34" }; + }); + // --------------------------------------------------------------------- // 9c2 — Der P1-Nachweis: die Data-API veroeffentlicht NICHT (EYT-107) // --------------------------------------------------------------------- diff --git a/apps/web/e2e/read-through.spec.ts b/apps/web/e2e/read-through.spec.ts index 69c29ff4..94850c37 100644 --- a/apps/web/e2e/read-through.spec.ts +++ b/apps/web/e2e/read-through.spec.ts @@ -490,17 +490,34 @@ const NEU_ENDE = "15:00"; */ async function wocheOeffnen(seite: import("@playwright/test").Page): Promise { await seite.goto(SEITE); - await expect(seite.getByTestId("einsatzformular")).toBeVisible(); + // Seit EYT-147 steht das Formular im geschlossenen Inspector; das fruehere + // Wartesignal `einsatzformular` existiert im Ruhezustand nicht mehr. Der + // Ausloeser erscheint wie das Formular erst mit verarbeiteten `resources`, + // die Liste erst mit den Zuweisungen — beide zusammen heissen weiterhin, + // dass die Antwort vollstaendig verarbeitet ist. + await expect(seite.getByTestId("werkbank-einsatz-anlegen")).toBeVisible(); await expect(seite.getByTestId("planungsfenster-liste")).toBeVisible(); return sichtbareZuweisungen(seite); } +/** + * Den Erstellungs-Inspector oeffnen, falls er zu ist (EYT-147). Idempotent, + * damit Folgeschritte innerhalb eines Tests nicht doppelt klicken. + */ +async function inspectorOeffnen(seite: import("@playwright/test").Page): Promise { + if ((await seite.getByTestId("einsatzformular").count()) === 0) { + await seite.getByTestId("werkbank-einsatz-anlegen").click(); + } + await expect(seite.getByTestId("einsatzformular")).toBeVisible(); +} + async function formularAusfuellen( seite: import("@playwright/test").Page, beginn: string, ende: string, datum = NEU_DATUM, ): Promise { + await inspectorOeffnen(seite); await seite.getByTestId("feld-employee").selectOption(A_PERSON); await seite.getByTestId("feld-worksite").selectOption(A_BAUSTELLE); await seite.getByTestId("feld-datum").fill(datum); @@ -513,7 +530,8 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { page, }) => { await page.goto(SEITE); - await expect(page.getByTestId("einsatzformular")).toBeVisible(); + await expect(page.getByTestId("werkbank-einsatz-anlegen")).toBeVisible(); + await inspectorOeffnen(page); // Die Namen stammen aus e2e/harness/seed.sql und existieren nirgends im // Clientcode — waeren sie eine Fixture, stuende hier ein anderer Text. @@ -543,6 +561,7 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { }); await page.goto(SEITE); + await inspectorOeffnen(page); await page.getByTestId("feld-employee").selectOption(A_PERSON); await page.getByTestId("feld-worksite").selectOption(A_BAUSTELLE); // Datum, Beginn und Ende fehlen absichtlich. @@ -717,6 +736,7 @@ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", () }) => { await page.setViewportSize(fall.viewport); await wocheOeffnen(page); + await inspectorOeffnen(page); const employee = page.getByTestId("feld-employee"); const worksite = page.getByTestId("feld-worksite"); diff --git a/apps/web/e2e/staging/journey.pwtest.ts b/apps/web/e2e/staging/journey.pwtest.ts index 9270db5d..a2644e9f 100644 --- a/apps/web/e2e/staging/journey.pwtest.ts +++ b/apps/web/e2e/staging/journey.pwtest.ts @@ -132,6 +132,11 @@ async function einsatzAnlegen( page: Page, eingabe: { mitarbeiter: string; baustelle: string; datum: string }, ): Promise { + // EYT-147: das Formular steht im Inspector und ist erst nach „Einsatz + // anlegen" im Baum. Idempotent gegen einen bereits offenen Inspector. + if ((await page.getByTestId("einsatzformular").count()) === 0) { + await page.getByTestId("werkbank-einsatz-anlegen").click(); + } await page.getByTestId("feld-employee").selectOption({ label: eingabe.mitarbeiter }); await page.getByTestId("feld-worksite").selectOption({ label: eingabe.baustelle }); await page.getByTestId("feld-datum").fill(eingabe.datum); @@ -301,6 +306,17 @@ test.describe.serial("EYT-142 Staging-Kernreise", () => { await schnappschuss(seite, "06-fokus-publish-1440"); } if (testid === "feld-employee") fokusEmployee = true; + // EYT-147: das Formular liegt im Inspector. Erreicht die Tastatur den + // Ausloeser, oeffnet Enter ihn, und der Fokus landet programmatisch im + // ersten Formularfeld — genau das wird hier mitgemessen. + if (testid === "werkbank-einsatz-anlegen" && !fokusEmployee) { + await seite.keyboard.press("Enter"); + const nachOeffnen = await seite.evaluate( + () => document.activeElement?.getAttribute("data-testid") ?? "", + ); + if (nachOeffnen !== "") erreicht.push(nachOeffnen); + if (nachOeffnen === "feld-employee") fokusEmployee = true; + } } expect(fokusEmployee, `Formularfeld per Tastatur in <=${budget} Tabs`).toBe(true); expect(fokusPublish, `Publish-Knopf per Tastatur in <=${budget} Tabs`).toBe(true); diff --git a/apps/web/lib/wochenraster.ts b/apps/web/lib/wochenraster.ts new file mode 100644 index 00000000..ed42b9ee --- /dev/null +++ b/apps/web/lib/wochenraster.ts @@ -0,0 +1,181 @@ +/** + * Wochenraster der Dispositionswerkbank (EYT-147 Slice 1). + * + * ## Was diese Datei ist — und was sie bewusst nicht ist + * + * Eine reine Zuordnungsfunktion: sie legt die Einsätze einer Serverantwort auf + * die sieben Kalendertage ihrer Woche. Sie erfindet keinen fachlichen Zustand, + * rechnet keine Woche und trifft keine Planungsentscheidung — Wochengrenze, + * Kalendertage und die Zuordnung eines Zeitpunkts zu einem Tag kommen + * vollständig aus `@easytree/domain` (`planningWeekDateRange`, `dayAfter`, + * `localBusinessDate`) und `@easytree/contracts` (`parseIsoWeekKey`). Eine + * eigene Tagesrechnung hier wäre die zweite Kalenderregel neben der Domain — + * und beide liefen genau an DST- und Jahresgrenzen auseinander. + * + * ## Ein Einsatz gehört zum Tag seines BEGINNS + * + * Dieselbe Regel, mit der `planningWeekOf` eine Nachtschicht in der Woche + * ihres Beginns hält: der Kalendertag eines Einsatzes ist der Tag, an dem er + * in der Zone der Organisation beginnt. Ein Einsatz von Montag 22:00 bis + * Dienstag 06:00 steht auf der Montags-Spalte — genau dort fängt die Kolonne + * an zu arbeiten. + * + * ## Nichts verschwindet + * + * Ein Einsatz, dessen Beginn NICHT in einem der sieben Tage liegt, wandert + * sichtbar nach `ausserhalb` statt still wegzufallen — eine Antwort, die der + * Server so eigentlich nicht liefern kann (der Create-Pfad vergleicht die + * Woche), aber „kann eigentlich nicht sein" ist kein Renderpfad. Dasselbe + * fail-sichtbare Prinzip gilt für die ganze Ableitung: eine unbekannte Zone + * oder ein unlesbarer Wochenschlüssel ergeben `art: "unbestimmbar"` mit + * Grund, und die Ansicht fällt auf die flache Liste zurück, statt ein leeres + * Raster als „nichts geplant" auszugeben. + * + * ## Die Zeitbeschriftung ist Darstellung, keine Wahrheit + * + * `zeitText` formatiert einen UTC-Zeitpunkt als Wanduhrzeit der Organisation + * über `Intl.DateTimeFormat` mit AUSDRÜCKLICHER Zone und festem `de-DE` — + * nie über die Laufzeitumgebung. Die maßgeblichen Werte bleiben die + * UTC-Intervalle des Vertrags; sie stehen unverändert als `data`-Attribute im + * Markup. + */ +import { parseIsoWeekKey } from "@easytree/contracts"; +import type { AssignmentDto } from "@easytree/contracts"; +import { + compareLocalBusinessDate, + createTimeZone, + dayAfter, + localBusinessDate, + planningWeekDateRange, +} from "@easytree/domain"; +import type { LocalBusinessDate } from "@easytree/domain"; + +/** Montag steht per Konstruktion an Index 0 — `planningWeekDateRange.monday`. */ +const WOCHENTAGSNAMEN = [ + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag", +] as const; + +export interface Wochentag { + /** Kanonischer Kalendertag als `YYYY-MM-DD` — für `data`-Anker und Schlüssel. */ + readonly tagKey: string; + /** Ausgeschriebener Wochentag, etwa `Montag`. */ + readonly wochentagsText: string; + /** Kalendertag als `TT.MM.`, ohne Jahr — das Jahr trägt die Wochenüberschrift. */ + readonly datumsText: string; + /** Einsätze, deren Beginn in der Organisationszone auf diesen Tag fällt — nach Beginn sortiert. */ + readonly einsaetze: readonly AssignmentDto[]; +} + +export type Wochenraster = + | { + readonly art: "raster"; + readonly tage: readonly Wochentag[]; + /** Einsätze, deren Beginn ausserhalb der sieben Tage liegt — sichtbar, nie verschluckt. */ + readonly ausserhalb: readonly AssignmentDto[]; + } + | { + readonly art: "unbestimmbar"; + readonly grund: "zone-unbekannt" | "woche-unlesbar"; + }; + +function zweistellig(wert: number): string { + return String(wert).padStart(2, "0"); +} + +function tagKey(tag: LocalBusinessDate): string { + return `${String(tag.year).padStart(4, "0")}-${zweistellig(tag.month)}-${zweistellig(tag.day)}`; +} + +/** + * Sortierung innerhalb eines Tages: Beginn, dann Ende, dann Id. + * + * Der Vergleich läuft über `Date.parse`, nicht über die Zeichenkette: der + * Vertrag normalisiert die Schreibweise des Instants nicht, und `+02:00` + * gegen `Z` sortiert lexikalisch falsch. Die Id als letzter Schlüssel ist ein + * schlichter Ordinalvergleich — `localeCompare` hinge an der Umgebung. + */ +function nachBeginn(a: AssignmentDto, b: AssignmentDto): number { + const beginn = Date.parse(a.interval.startUtc) - Date.parse(b.interval.startUtc); + if (beginn !== 0) return beginn; + const ende = Date.parse(a.interval.endUtc) - Date.parse(b.interval.endUtc); + if (ende !== 0) return ende; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * Legt die Einsätze einer Woche auf ihre sieben Kalendertage. + * + * @param fenster Ausschnitt der Serverantwort: Woche, Zone, Einsätze. + * @returns Das vollständige Raster, oder `unbestimmbar` mit Grund — nie ein + * halbes Raster, in dem Einsätze fehlen. + */ +export function wochenraster(fenster: { + readonly weekKey: string; + readonly timeZone: string; + readonly assignments: readonly AssignmentDto[]; +}): Wochenraster { + const woche = parseIsoWeekKey(fenster.weekKey); + if (!woche.ok) return { art: "unbestimmbar", grund: "woche-unlesbar" }; + + const zone = createTimeZone(fenster.timeZone); + if (!zone.ok) return { art: "unbestimmbar", grund: "zone-unbekannt" }; + + const bereich = planningWeekDateRange(woche.week); + const tage: LocalBusinessDate[] = [bereich.monday]; + for (let i = 1; i < 7; i += 1) { + const vortag = tage[i - 1]; + if (vortag === undefined) break; // unerreichbar; hält noUncheckedIndexedAccess + tage.push(dayAfter(vortag)); + } + + const jeTag: AssignmentDto[][] = tage.map(() => []); + const ausserhalb: AssignmentDto[] = []; + for (const einsatz of fenster.assignments) { + const beginnTag = localBusinessDate(new Date(einsatz.interval.startUtc), zone.timeZone); + const index = tage.findIndex((tag) => compareLocalBusinessDate(tag, beginnTag) === 0); + if (index === -1) { + ausserhalb.push(einsatz); + } else { + jeTag[index]?.push(einsatz); + } + } + + return { + art: "raster", + tage: tage.map((tag, index) => ({ + tagKey: tagKey(tag), + wochentagsText: WOCHENTAGSNAMEN[index] ?? "", + datumsText: `${zweistellig(tag.day)}.${zweistellig(tag.month)}.`, + einsaetze: [...(jeTag[index] ?? [])].sort(nachBeginn), + })), + ausserhalb: [...ausserhalb].sort(nachBeginn), + }; +} + +/** + * Wanduhrzeit eines UTC-Zeitpunkts in einer benannten Zone, als `HH:MM`. + * + * Ausdrücklich `de-DE`, `hour12: false` und die ÜBERGEBENE Zone — nichts an + * dieser Formatierung liest die Laufzeitumgebung. Eine unbrauchbare Eingabe + * ergibt sichtbar den Rohwert statt einer geratenen Zeit. + */ +export function zeitText(instantUtc: string, timeZone: string): string { + const zeitpunkt = new Date(instantUtc); + if (Number.isNaN(zeitpunkt.getTime())) return instantUtc; + try { + return new Intl.DateTimeFormat("de-DE", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(zeitpunkt); + } catch { + return instantUtc; + } +} diff --git a/apps/web/test/dispositionswerkbank.test.tsx b/apps/web/test/dispositionswerkbank.test.tsx new file mode 100644 index 00000000..a2ead894 --- /dev/null +++ b/apps/web/test/dispositionswerkbank.test.tsx @@ -0,0 +1,214 @@ +/** + * Dispositionswerkbank Slice 1 (EYT-147) — Woche als Achse, Inspector, eine + * primaere Aktion. + * + * ## Gegenmutationen (ausgefuehrt, siehe PR-Beschreibung) + * + * 1. In `planning-window-view.tsx` `useState(false)` des Inspectors auf + * `useState(true)` stellen → „der Inspector ist anfangs geschlossen" rot. + * 2. Den Ausloeser unabhaengig vom Stand als `PrimaryAction` rendern → + * „im Entwurfszustand ist Veroeffentlichen die einzige primaere Aktion" + * rot (und `planungs-werkbank.test.tsx` zaehlt 2). + * 3. Die Tageszuordnung in `lib/wochenraster.ts` auf das UTC-Datum stellen → + * dort rot (`wochenraster.test.ts`), nicht hier: die Rechnung hat genau + * einen Ort. + */ +import type { GatewayResult, PlanningGateway, PlanningWindow } from "@easytree/contracts"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { PlanningWindowView } from "../components/planning-window-view"; +import { PlanningGatewayProvider } from "../lib/planning-gateway-provider"; + +const PERSON = "22222222-2222-4222-8222-222222222222"; +const BAUSTELLE = "33333333-3333-4333-8333-333333333333"; + +function fenster(teil: Partial): PlanningWindow { + return { + weekKey: "2026-W36", + timeZone: "Europe/Berlin", + assignments: [], + sourceVersion: null, + publishedVersionId: null, + resources: { + employees: [{ id: PERSON, label: "Anna Berg", active: true }], + worksites: [{ id: BAUSTELLE, label: "Baustelle Nord", active: true }], + }, + ...teil, + }; +} + +function einsatz(id: string, startUtc: string, endUtc: string) { + return { id, employeeId: PERSON, worksiteId: BAUSTELLE, interval: { startUtc, endUtc } }; +} + +function gatewayMit(result: GatewayResult): PlanningGateway { + return { + getPlanningWindow: () => Promise.resolve(result), + validateDraft: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + createAssignment: () => { + throw new Error("in diesem Test nicht benutzt"); + }, + publishPlan: () => { + throw new Error("in diesem Test nicht benutzt"); + }, + }; +} + +function rendern(wert: PlanningWindow, darfVeroeffentlichen = false): void { + render( + + + , + ); +} + +afterEach(cleanup); + +describe("EYT-147 — die Woche ist die Flaeche", () => { + it("zeigt alle sieben Wochentage der Woche aus der Serverantwort", async () => { + rendern(fenster({})); + const liste = await screen.findByTestId("planungsfenster-liste"); + const koepfe = [...liste.querySelectorAll(".wochenraster__tagkopf")].map( + (kopf) => kopf.textContent ?? "", + ); + expect(koepfe).toHaveLength(7); + expect(koepfe[0]).toContain("Montag"); + expect(koepfe[0]).toContain("31.08."); + expect(koepfe[6]).toContain("Sonntag"); + expect(koepfe[6]).toContain("06.09."); + }); + + it("legt einen Einsatz auf den Kalendertag seines Beginns in der Organisationszone", async () => { + // 22:30 UTC am Montag ist in Berlin bereits Dienstag, 00:30 — die Karte + // steht am Dienstag. Eine UTC-Zuordnung legte sie auf den Montag. + rendern( + fenster({ + sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" }, + assignments: [ + einsatz( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "2026-08-31T22:30:00.000Z", + "2026-09-01T04:00:00.000Z", + ), + ], + }), + ); + const liste = await screen.findByTestId("planungsfenster-liste"); + const dienstag = liste.querySelector('[data-tag="2026-09-01"]'); + expect(dienstag).not.toBeNull(); + const karte = within(dienstag as HTMLElement).getByRole("listitem"); + expect(karte.getAttribute("data-assignment-id")).toBe("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + // Die Karte nennt Baustelle, Person und Wanduhrzeit — nicht die Uuid. + expect(karte.textContent).toContain("Baustelle Nord"); + expect(karte.textContent).toContain("Anna Berg"); + expect(karte.textContent).toContain("00:30"); + const montag = liste.querySelector('[data-tag="2026-08-31"]'); + expect(within(montag as HTMLElement).queryAllByRole("listitem")).toHaveLength(0); + }); + + it("verschluckt einen Einsatz ausserhalb der Woche nicht, sondern stellt ihn aus", async () => { + rendern( + fenster({ + sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" }, + assignments: [ + einsatz( + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "2026-09-14T06:00:00.000Z", + "2026-09-14T14:00:00.000Z", + ), + ], + }), + ); + const liste = await screen.findByTestId("planungsfenster-liste"); + const ausserhalb = liste.querySelector(".wochenraster__ausserhalb"); + expect(ausserhalb).not.toBeNull(); + expect( + within(ausserhalb as HTMLElement) + .getByRole("listitem") + .getAttribute("data-assignment-id"), + ).toBe("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + }); +}); + +describe("EYT-147 — der Erstellungs-Inspector", () => { + it("ist anfangs geschlossen: das Formular steht erst nach „Einsatz anlegen“ im Baum", async () => { + rendern(fenster({})); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + expect(screen.queryByTestId("einsatzformular")).toBeNull(); + expect(ausloeser.getAttribute("aria-expanded")).toBe("false"); + + await userEvent.click(ausloeser); + expect(await screen.findByTestId("einsatzformular")).toBeTruthy(); + expect(ausloeser.getAttribute("aria-expanded")).toBe("true"); + }); + + it("setzt beim Oeffnen den Fokus in das erste Bedienelement des Formulars", async () => { + rendern(fenster({})); + await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen")); + await screen.findByTestId("einsatzformular"); + expect(document.activeElement).toBe(screen.getByTestId("feld-employee")); + }); + + it("schliesst mit Escape und stellt den Fokus auf den Ausloeser zurueck", async () => { + rendern(fenster({})); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + await userEvent.click(ausloeser); + await screen.findByTestId("einsatzformular"); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByTestId("einsatzformular")).toBeNull(); + expect(document.activeElement).toBe(ausloeser); + }); + + it("schliesst ueber die Schaltflaeche und stellt den Fokus zurueck", async () => { + rendern(fenster({})); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + await userEvent.click(ausloeser); + await userEvent.click(await screen.findByTestId("werkbank-inspector-schliessen")); + expect(screen.queryByTestId("einsatzformular")).toBeNull(); + expect(document.activeElement).toBe(ausloeser); + }); +}); + +describe("EYT-147 — genau eine primaere Aktion je Zustand", () => { + it("macht im veroeffentlichbaren Entwurf das Veroeffentlichen zur einzigen primaeren Aktion", async () => { + rendern( + fenster({ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" } }), + true, + ); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + const primaere = document.querySelectorAll(".eyt-primary-action"); + expect(primaere).toHaveLength(1); + expect(primaere[0]?.getAttribute("data-testid")).toBe("planung-veroeffentlichen"); + expect(ausloeser.classList.contains("eyt-primary-action")).toBe(false); + }); + + it("macht ohne Veroeffentlichungsweg das Anlegen zur einzigen primaeren Aktion", async () => { + rendern( + fenster({ + sourceVersion: { id: "44444444-4444-4444-8444-444444444444", state: "published" }, + publishedVersionId: "44444444-4444-4444-8444-444444444444", + }), + true, + ); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + expect(screen.queryByTestId("planung-veroeffentlichen")).toBeNull(); + const primaere = document.querySelectorAll(".eyt-primary-action"); + expect(primaere).toHaveLength(1); + expect(primaere[0]).toBe(ausloeser); + }); + + it("laesst dem Entwurf OHNE planning.publish die Anlage als primaere Aktion", async () => { + rendern( + fenster({ sourceVersion: { id: "55555555-5555-4555-8555-555555555555", state: "draft" } }), + false, + ); + const ausloeser = await screen.findByTestId("werkbank-einsatz-anlegen"); + expect(screen.queryByTestId("planung-veroeffentlichen")).toBeNull(); + expect(ausloeser.classList.contains("eyt-primary-action")).toBe(true); + }); +}); diff --git a/apps/web/test/planning-publish-action.test.tsx b/apps/web/test/planning-publish-action.test.tsx index 7e0040b2..29e86760 100644 --- a/apps/web/test/planning-publish-action.test.tsx +++ b/apps/web/test/planning-publish-action.test.tsx @@ -112,6 +112,13 @@ describe("Zustandsdarstellung", () => { expect(marke.textContent).toContain("Entwurf"); }); + it("zeigt ohne Version KEINE Standmarke — es gibt keinen Stand zu benennen (EYT-147)", () => { + // Eine „Entwurf"-Marke ueber einer versionslosen Woche waere erfundene + // Information; die Wochenansicht sagt dort bereits „Keine Version". + zeichne({ sourceVersionId: null }); + expect(screen.queryByTestId("planung-stand-marke")).toBeNull(); + }); + it("zeigt nach Erfolg den veroeffentlichten Stand mit Textmarke", async () => { zeichne(); fireEvent.click(aktion() as HTMLElement); diff --git a/apps/web/test/planning-window-view.test.tsx b/apps/web/test/planning-window-view.test.tsx index 66058407..dd228333 100644 --- a/apps/web/test/planning-window-view.test.tsx +++ b/apps/web/test/planning-window-view.test.tsx @@ -71,6 +71,16 @@ const PLANBARE_WOCHE: PlanningWindow = { // ein echter Befund aussieht. afterEach(cleanup); +/** + * Seit EYT-147 steht das Einsatzformular im seitlichen Inspector und ist erst + * nach „Einsatz anlegen" im Baum. Der erste Zugriff WARTET auf den Ausloeser + * (der erscheint erst mit dem geladenen Fenster), der zweite auf das Formular. + */ +async function inspectorOeffnen(): Promise { + await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen")); + await screen.findByTestId("einsatzformular"); +} + describe("PlanningWindowView", () => { it("zeigt zuerst den Ladezustand", () => { renderWith({ ok: true, value: LEERE_WOCHE }); @@ -239,7 +249,7 @@ describe("PlanningWindowView", () => { , ); - await screen.findByTestId("einsatzformular"); + await inspectorOeffnen(); await userEvent.selectOptions( screen.getByTestId("feld-employee"), @@ -309,7 +319,7 @@ describe("PlanningWindowView", () => { , ); - await screen.findByTestId("einsatzformular"); + await inspectorOeffnen(); await userEvent.selectOptions( screen.getByTestId("feld-employee"), @@ -404,7 +414,7 @@ describe("PlanningWindowView", () => { , ); - await screen.findByTestId("einsatzformular"); + await inspectorOeffnen(); // Vor dem Speichern: genau EIN Lesevorgang, und noch keine Zuweisung. expect(leseversuch).toBe(1); diff --git a/apps/web/test/werkbank-oberflaechenguards.test.tsx b/apps/web/test/werkbank-oberflaechenguards.test.tsx new file mode 100644 index 00000000..545a9581 --- /dev/null +++ b/apps/web/test/werkbank-oberflaechenguards.test.tsx @@ -0,0 +1,194 @@ +/** + * EYT-147 PO-Review-Reparatur — Oberflaechen-Guards der Dispositionswerkbank. + * + * ## R1 — technische Identifikatoren sind kein Planertext + * + * Die Werkbank zeigte eine serverseitige Planversions-Id als sichtbaren Satz + * („Zuletzt veroeffentlicht: ") und die Publish-Bestaetigung nannte Id + * UND rohen ISO-Zeitstempel im Fliesstext. Beides ist Serverwahrheit, aber + * keine Planerinformation. Die Guards hier verlangen beides zugleich: + * + * - KEINE rohe UUID und KEIN roher ISO-Instant im Text der Werkbank; + * - DIESELBEN echten Server-Ids weiterhin im vorgesehenen `data-*`-Seam, + * an dem `read-through.spec.ts`, `auth-journey` und die Unit-Suiten haengen. + * + * Der Textvergleich laeuft ueber `textContent` des gesamten Baums — bewusst + * einschliesslich visuell versteckter Texte: auch ein `VisuallyHidden` mit + * einer UUID waere fuer Screenreader-Nutzer eine zugemutete Id. + * + * ## R2 — genau EINE Statusmarke fuer den Planstand + * + * Vor der Reparatur trugen `planungsfenster-stand-abzeichen` (Wochenansicht) + * und `planung-stand-marke` (Publish-Baustein) beide ein sichtbares + * `StatusBadge` mit derselben Aussage („Entwurf" neben „Entwurf"). Der Guard + * zaehlt die `StatusBadge`-Elemente der gerenderten Werkbank und verlangt + * genau eines — das der Wochenansicht. Die Marke des Publish-Bausteins bleibt + * als unsichtbarer Seam (Testanker, Screenreader, `data-stand`) erhalten. + * + * ## Gegenmutationen (ausgefuehrt, gemessen, zurueckgenommen — siehe PR) + * + * 1. `planning-window-view.tsx`: die sichtbare Zeile + * `Zuletzt veroeffentlicht: ${fenster.publishedVersionId}` wiederherstellen + * → „nennt im Text keine rohe UUID" rot. + * 2. `planning-publish-action.tsx`: in `planung-stand-marke` wieder ein + * `StatusBadge` rendern → „traegt genau eine Statusmarke" rot. + */ +import type { GatewayResult, PlanningGateway, PlanningWindow } from "@easytree/contracts"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PlanningWindowView } from "../components/planning-window-view"; +import { PlanningGatewayProvider } from "../lib/planning-gateway-provider"; +import { + EINSATZ_VOM_SERVER, + ENTWURF_VERSION, + VEROEFFENTLICHTE_VERSION, + fensterMitEntwurf, + fensterVeroeffentlicht, +} from "./helpers/werkbank-daten"; + +const WOCHE = "2026-W34"; + +/** RFC-4122-Form, unabhaengig von Gross-/Kleinschreibung. */ +const UUID_MUSTER = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +/** Roher ISO-Instant (`2026-08-19T12:00…`) — als Text niemals Planer-UI. */ +const ISO_INSTANT_MUSTER = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/; + +/** Entwurf UEBER einer veroeffentlichten Version — beide Server-Ids im Spiel. */ +function fensterEntwurfUeberVeroeffentlicht(): PlanningWindow { + return { ...fensterMitEntwurf(WOCHE), publishedVersionId: VEROEFFENTLICHTE_VERSION }; +} + +function gatewayMit( + antwort: () => GatewayResult, + publishPlan?: PlanningGateway["publishPlan"], +): PlanningGateway { + return { + getPlanningWindow: () => Promise.resolve(antwort()), + validateDraft: () => { + throw new Error("in dieser Suite nicht benutzt"); + }, + createAssignment: () => { + throw new Error("in dieser Suite nicht benutzt"); + }, + publishPlan: + publishPlan ?? + (() => { + throw new Error("in dieser Suite nicht benutzt"); + }), + }; +} + +function rendern(gateway: PlanningGateway, darfVeroeffentlichen: boolean): void { + render( + + + , + ); +} + +afterEach(cleanup); + +describe("R1 — kein technischer Identifikator im Text der Werkbank", () => { + it("nennt im Text keine rohe UUID — die Server-Ids stehen im data-Seam", async () => { + rendern( + gatewayMit(() => ({ ok: true, value: fensterEntwurfUeberVeroeffentlicht() })), + true, + ); + await screen.findByTestId("planungsfenster-liste"); + + // Kein Textknoten des Baums traegt eine UUID — sichtbar oder versteckt. + expect(document.body.textContent ?? "").not.toMatch(UUID_MUSTER); + + // Und zwar NICHT, weil die Ids fehlen: dieselben echten Server-Ids + // stehen weiterhin exakt an ihren technischen Seams. + const version = screen.getByTestId("planungsfenster-version"); + expect(version.getAttribute("data-published-version-id")).toBe(VEROEFFENTLICHTE_VERSION); + expect(version.getAttribute("data-source-version-id")).toBe(ENTWURF_VERSION); + expect(document.querySelector(`[data-assignment-id="${EINSATZ_VOM_SERVER}"]`)).not.toBeNull(); + }); + + it("nennt im Text keinen rohen ISO-Zeitstempel", async () => { + rendern( + gatewayMit(() => ({ ok: true, value: fensterEntwurfUeberVeroeffentlicht() })), + true, + ); + await screen.findByTestId("planungsfenster-liste"); + expect(document.body.textContent ?? "").toMatch(/\d{2}:\d{2}/); // Wanduhrzeit ja … + expect(document.body.textContent ?? "").not.toMatch(ISO_INSTANT_MUSTER); // … Instant nein. + }); + + it("haelt auch die Publish-Bestaetigung frei von UUID und Instant — beide bleiben als data-Attribute", async () => { + const VERSION_ID = ENTWURF_VERSION; + const PUBLISHED_AT = "2026-08-19T12:34:56.000Z"; + let veroeffentlicht = false; + const publishPlan = vi.fn(() => { + veroeffentlicht = true; + return Promise.resolve({ + ok: true as const, + value: { + versionId: VERSION_ID, + weekKey: WOCHE, + publishedAtUtc: PUBLISHED_AT, + assignmentIds: [EINSATZ_VOM_SERVER], + }, + }); + }); + rendern( + gatewayMit( + () => ({ + ok: true, + value: veroeffentlicht ? fensterVeroeffentlicht(WOCHE) : fensterMitEntwurf(WOCHE), + }), + publishPlan, + ), + true, + ); + + await userEvent.click(await screen.findByTestId("planung-veroeffentlichen")); + const erfolg = await screen.findByTestId("planung-publish-erfolg"); + + expect(erfolg.textContent ?? "").not.toMatch(UUID_MUSTER); + expect(erfolg.textContent ?? "").not.toMatch(ISO_INSTANT_MUSTER); + // Serverwahrheit unveraendert am Seam: exakt die Id und der Instant der + // Serverantwort, nichts Erfundenes. + expect(erfolg.getAttribute("data-published-version-id")).toBe(VERSION_ID); + expect(erfolg.getAttribute("data-published-at-utc")).toBe(PUBLISHED_AT); + // Der ganze Baum bleibt sauber, auch mit sichtbarer Erfolgsleiste. + expect(document.body.textContent ?? "").not.toMatch(UUID_MUSTER); + }); +}); + +describe("R2 — genau eine Statusmarke fuer den Planstand", () => { + it("traegt im veroeffentlichbaren Entwurf genau EIN StatusBadge — das der Wochenansicht", async () => { + rendern( + gatewayMit(() => ({ ok: true, value: fensterMitEntwurf(WOCHE) })), + true, + ); + await screen.findByTestId("planungsfenster-liste"); + + const marken = document.querySelectorAll(".eyt-status-badge"); + expect(marken).toHaveLength(1); + expect(marken[0]?.getAttribute("data-testid")).toBe("planungsfenster-stand-abzeichen"); + + // Der Seam des Publish-Bausteins bleibt: Testanker, `data-stand` und + // Screenreader-Text existieren weiter — nur die zweite SICHTBARE Marke + // ist weg. + const seam = screen.getByTestId("planung-stand-marke"); + expect(seam.getAttribute("data-stand")).toBe("draft"); + expect(seam.textContent ?? "").toContain("Entwurf"); + }); + + it("traegt im veroeffentlichten Stand genau EIN StatusBadge", async () => { + rendern( + gatewayMit(() => ({ ok: true, value: fensterVeroeffentlicht(WOCHE) })), + false, + ); + await screen.findByTestId("planungsfenster-liste"); + + const marken = document.querySelectorAll(".eyt-status-badge"); + expect(marken).toHaveLength(1); + expect(marken[0]?.getAttribute("data-testid")).toBe("planungsfenster-stand-abzeichen"); + }); +}); diff --git a/apps/web/test/werkbank-serverwahrheit.test.tsx b/apps/web/test/werkbank-serverwahrheit.test.tsx index fc705df4..c6a21049 100644 --- a/apps/web/test/werkbank-serverwahrheit.test.tsx +++ b/apps/web/test/werkbank-serverwahrheit.test.tsx @@ -75,6 +75,12 @@ vi.mock("next/navigation", async () => { const MITTWOCH_KW34 = new Date("2026-08-19T12:00:00.000Z"); const RECHTE = ["planning.read", "planning.write"]; +// Sieben userEvent-Schritte je Reise (seit EYT-147 einer mehr: der Inspector +// wird zuerst geoeffnet) liegen unter Volllast ueber dem 5-s-Standard — +// gemessen 31.08.2026 (5133 ms, nur unter parallelem turbo-Lauf). Das Budget +// ist Robustheit, keine Abschwaechung: es aendert keine Zusicherung. +vi.setConfig({ testTimeout: 15_000 }); + beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(MITTWOCH_KW34); @@ -185,6 +191,10 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach * sie es auch. */ async function einsatzAnlegen(datum: string): Promise { + // Seit EYT-147 steht das Formular im Inspector: erst den Ausloeser + // druecken (er erscheint mit dem geladenen Fenster), dann warten die + // Feldzugriffe wie zuvor auf einen ZUSTAND, nicht auf eine Frist. + await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen")); await userEvent.selectOptions(await screen.findByTestId("feld-employee"), PERSON_ID); await userEvent.selectOptions(screen.getByTestId("feld-worksite"), BAUSTELLE_ID); await userEvent.type(screen.getByTestId("feld-datum"), datum); diff --git a/apps/web/test/wochenraster.test.ts b/apps/web/test/wochenraster.test.ts new file mode 100644 index 00000000..a5050374 --- /dev/null +++ b/apps/web/test/wochenraster.test.ts @@ -0,0 +1,195 @@ +/** + * Wochenraster der Dispositionswerkbank (EYT-147 Slice 1). + * + * Feste Wochen, feste Zeitpunkte — kein `new Date()` ohne Argument. Die + * tragenden Zusicherungen: + * + * - Der Kalendertag eines Einsatzes folgt der ZONE der Organisation, nicht dem + * UTC-Datum. Gegenmutation: in `wochenraster` den Beginn per + * `startUtc.slice(0, 10)` einem Tag zuordnen → „22:30 UTC ist schon der + * Folgetag in Berlin" geht rot. + * - Kein Einsatz verschwindet: was nicht in die Woche fällt, steht in + * `ausserhalb`. Gegenmutation: den `ausserhalb`-Zweig zu `continue` machen → + * die Zählzusicherung geht rot. + * - Die Tagessortierung hängt am BEGINN, nicht an der Id: die Fixtures + * widersprechen der Id-Reihenfolge absichtlich (Sortierschlüssel dürfen + * nicht vom einverstandenen Tiebreak maskiert werden). + */ +import { describe, expect, it } from "vitest"; + +import type { AssignmentDto } from "@easytree/contracts"; + +import { wochenraster, zeitText } from "../lib/wochenraster"; + +const ZONE = "Europe/Berlin"; + +function einsatz(id: string, startUtc: string, endUtc: string): AssignmentDto { + return { + id, + employeeId: "33333333-3333-4333-8333-333333333333", + worksiteId: "44444444-4444-4444-8444-444444444444", + interval: { startUtc, endUtc }, + } as AssignmentDto; +} + +describe("wochenraster", () => { + it("legt die Woche 2026-W36 als Montag 31.08. bis Sonntag 06.09. aus", () => { + const raster = wochenraster({ weekKey: "2026-W36", timeZone: ZONE, assignments: [] }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + + expect(raster.tage).toHaveLength(7); + expect(raster.tage.map((t) => t.wochentagsText)).toEqual([ + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + "Sonntag", + ]); + expect(raster.tage[0]?.datumsText).toBe("31.08."); + expect(raster.tage[0]?.tagKey).toBe("2026-08-31"); + expect(raster.tage[6]?.datumsText).toBe("06.09."); + expect(raster.tage[6]?.tagKey).toBe("2026-09-06"); + expect(raster.ausserhalb).toHaveLength(0); + }); + + it("überquert die Jahresgrenze: 2026-W53 endet am 03.01.2027", () => { + const raster = wochenraster({ weekKey: "2026-W53", timeZone: ZONE, assignments: [] }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.tage[0]?.tagKey).toBe("2026-12-28"); + expect(raster.tage[6]?.tagKey).toBe("2027-01-03"); + }); + + it("ordnet einen Einsatz dem Kalendertag seines Beginns in der Organisationszone zu", () => { + // 22:30 UTC am 31.08. ist in Berlin bereits der 01.09., 00:30 — der + // Einsatz gehört auf den DIENSTAG. Eine UTC-Datumszuordnung legte ihn auf + // den Montag. + const nachtbeginn = einsatz( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "2026-08-31T22:30:00.000Z", + "2026-09-01T04:00:00.000Z", + ); + const raster = wochenraster({ + weekKey: "2026-W36", + timeZone: ZONE, + assignments: [nachtbeginn], + }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.tage[0]?.einsaetze).toHaveLength(0); + expect(raster.tage[1]?.einsaetze.map((e) => e.id)).toEqual([nachtbeginn.id]); + }); + + it("hält eine Nachtschicht am Tag ihres Beginns — dieselbe Regel wie planningWeekOf", () => { + // Freitag 20:00 Berlin bis Samstag 04:00 Berlin: die Karte steht am Freitag. + const nachtschicht = einsatz( + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "2026-09-04T18:00:00.000Z", + "2026-09-05T02:00:00.000Z", + ); + const raster = wochenraster({ + weekKey: "2026-W36", + timeZone: ZONE, + assignments: [nachtschicht], + }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.tage[4]?.einsaetze.map((e) => e.id)).toEqual([nachtschicht.id]); + expect(raster.tage[5]?.einsaetze).toHaveLength(0); + }); + + it("sortiert einen Tag nach Beginn — gegen die Id-Reihenfolge", () => { + // Die Ids sind absichtlich GEGENLÄUFIG zum Beginn: bestünde die Sortierung + // aus dem Id-Tiebreak allein, wäre die Reihenfolge genau falsch. + const spaet = einsatz( + "11111111-0000-4000-8000-000000000001", + "2026-09-02T12:00:00.000Z", + "2026-09-02T14:00:00.000Z", + ); + const frueh = einsatz( + "99999999-0000-4000-8000-000000000009", + "2026-09-02T06:00:00.000Z", + "2026-09-02T08:00:00.000Z", + ); + const raster = wochenraster({ + weekKey: "2026-W36", + timeZone: ZONE, + assignments: [spaet, frueh], + }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.tage[2]?.einsaetze.map((e) => e.id)).toEqual([frueh.id, spaet.id]); + }); + + it("verschluckt keinen Einsatz ausserhalb der Woche — er steht sichtbar in ausserhalb", () => { + const fremd = einsatz( + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "2026-09-14T06:00:00.000Z", + "2026-09-14T14:00:00.000Z", + ); + const eigen = einsatz( + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "2026-09-01T06:00:00.000Z", + "2026-09-01T14:00:00.000Z", + ); + const raster = wochenraster({ + weekKey: "2026-W36", + timeZone: ZONE, + assignments: [fremd, eigen], + }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.ausserhalb.map((e) => e.id)).toEqual([fremd.id]); + const verteilt = raster.tage.reduce((summe, tag) => summe + tag.einsaetze.length, 0); + expect(verteilt + raster.ausserhalb.length).toBe(2); + }); + + it("meldet eine unbekannte Zone als unbestimmbar statt ein leeres Raster zu liefern", () => { + const raster = wochenraster({ + weekKey: "2026-W36", + timeZone: "Nicht/Vorhanden", + assignments: [], + }); + expect(raster).toEqual({ art: "unbestimmbar", grund: "zone-unbekannt" }); + }); + + it("meldet einen unlesbaren Wochenschlüssel als unbestimmbar", () => { + const raster = wochenraster({ weekKey: "keine-woche", timeZone: ZONE, assignments: [] }); + expect(raster).toEqual({ art: "unbestimmbar", grund: "woche-unlesbar" }); + }); + + it("ordnet in der DST-Endwoche den Umstellungssonntag korrekt zu", () => { + // 2026-W43: 19.10.–25.10.2026; am Sonntag, 25.10., endet die Sommerzeit. + // 00:30 UTC ist 02:30 CEST — noch Sonntag; die Karte steht am Sonntag. + const umstellung = einsatz( + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "2026-10-25T00:30:00.000Z", + "2026-10-25T06:00:00.000Z", + ); + const raster = wochenraster({ + weekKey: "2026-W43", + timeZone: ZONE, + assignments: [umstellung], + }); + if (raster.art !== "raster") throw new Error(`unerwartet: ${raster.art}`); + expect(raster.tage[6]?.tagKey).toBe("2026-10-25"); + expect(raster.tage[6]?.einsaetze.map((e) => e.id)).toEqual([umstellung.id]); + }); +}); + +describe("zeitText", () => { + it("formatiert einen UTC-Zeitpunkt als Berliner Wanduhrzeit", () => { + expect(zeitText("2026-09-01T06:00:00.000Z", ZONE)).toBe("08:00"); + }); + + it("formatiert Winterzeit mit dem Winter-Versatz", () => { + expect(zeitText("2026-12-01T06:00:00.000Z", ZONE)).toBe("07:00"); + }); + + it("gibt einen unlesbaren Zeitpunkt roh zurück statt zu raten", () => { + expect(zeitText("keine-zeit", ZONE)).toBe("keine-zeit"); + }); + + it("gibt bei unbekannter Zone den Rohwert zurück", () => { + expect(zeitText("2026-09-01T06:00:00.000Z", "Nicht/Vorhanden")).toBe( + "2026-09-01T06:00:00.000Z", + ); + }); +}); diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png b/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png new file mode 100644 index 00000000..39062d13 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/01-angemeldete-appshell.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png b/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png new file mode 100644 index 00000000..8d25d011 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/02-stundensatzverwaltung.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png b/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png new file mode 100644 index 00000000..618fd71a Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/03-benutzer-b-ohne-zugang.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png b/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png new file mode 100644 index 00000000..d67573bc Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/03-satzabloesung.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png b/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png new file mode 100644 index 00000000..a6497b87 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/04-feld-shell-320.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png b/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png new file mode 100644 index 00000000..d1b95b08 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/04-planung-entwurf.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png b/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png new file mode 100644 index 00000000..62c19d25 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/05-feld-shell-375.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png b/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png new file mode 100644 index 00000000..43747f8f Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/05-planung-veroeffentlicht.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png b/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png new file mode 100644 index 00000000..46c241e0 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/06-planung-zweiter-kontext.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png b/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png new file mode 100644 index 00000000..77334d4e Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/07-kosten-snapshot.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png b/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png new file mode 100644 index 00000000..425b6f1d Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/08-kosten-zweiter-kontext.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png b/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png new file mode 100644 index 00000000..ae644581 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/08b-member-ohne-kostenrecht.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png b/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png new file mode 100644 index 00000000..67f8d09f Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/09-kosten-gefiltert.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png b/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png new file mode 100644 index 00000000..69692f41 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/10-kosten-gefiltert-zweiter.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png new file mode 100644 index 00000000..3d1742b7 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1440.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png new file mode 100644 index 00000000..69272131 Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/11-werkbank-planung-1920.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png b/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png new file mode 100644 index 00000000..1a866eff Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/12-werkbank-inspector-1440.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png b/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png new file mode 100644 index 00000000..777dad4a Binary files /dev/null and b/docs/evidence/2026-08-31-eyt-147-slice-1/13-werkbank-einsatz-serverbestaetigt.png differ diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/README.md b/docs/evidence/2026-08-31-eyt-147-slice-1/README.md new file mode 100644 index 00000000..48b8015f --- /dev/null +++ b/docs/evidence/2026-08-31-eyt-147-slice-1/README.md @@ -0,0 +1,121 @@ +# EYT-147 Slice 1 — Dispositionswerkbank: Evidenzpaket + +> **Stand:** 31.08.2026, Europe/Berlin +> **Basis:** `origin/master` = `f8e96e4ceb4f00ae5f5ac777c6eb47aa8f766d6f` (Merge PR #100) +> **Branch:** `feat/eyt-147-dispositionswerkbank-slice-1` +> **Evidenzstufe dieses Pakets:** lokale Läufe auf dem Entwicklungsrechner (colima). +> Der ausgeführte CI-Lauf am PR-Head ist die höhere Stufe und wird im PR nachgetragen. + +## Was der Slice liefert + +`Planung öffnen → aktuelle Woche sofort verstehen → Einsatz räumlich in der Woche sehen → +Einsatz über den Inspector anlegen → bestätigten Serverzustand sehen → Entwurf eindeutig +erkennen → veröffentlichen → veröffentlichten Zustand erkennen → Reload zeigt denselben +serverseitigen Zustand.` + +`Edit` existiert im Serververtrag nicht und wurde **nicht erfunden** — siehe +[`ui-parity-matrix.md`](ui-parity-matrix.md) (CAPABILITY_GAP). + +## Lokale Läufe (Befehle und gemessene Ergebnisse) + +| Gate | Befehl | Ergebnis | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Format | `git ls-files -z \| xargs -0 pnpm exec prettier --ignore-unknown --check` | exit 0 | +| Lint + Typen | `pnpm exec turbo run lint typecheck --force` | `16 successful, 16 total`, `Cached: 0` | +| Unit-Tests | `EASYTREE_TEST_DB_URL= … pnpm exec turbo run test --force --env-mode=loose` | `10 successful, 10 total`, `Cached: 0` — Web: 40 Dateien / 474 Tests | +| Build | `env -u EASYTREE_API_PROXY_TARGET pnpm exec turbo run build --force` | `6 successful`, `Cached: 0` | +| web-smoke (Playwright) | `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3001 pnpm exec playwright test` | **29 passed** (shell-smoke 25 + planungswerkbank 4) | +| read-through (lokal emuliert) | Phasen aus `scripts/read-through-harness.sh`; Abweichung: `supabase start -x studio -x vector -x logflare` (vector startet unter colima nicht) | Phase 13: **7 passed** · Schreibpfad: **6 passed** · Nachweis 7: **1 passed** | +| auth-journey (lokal, echte GoTrue-Identität) | `pnpm exec playwright test -c e2e/auth-journey/config.ts` nach `supabase db reset` | **5 passed**, Teardown `restzeilen=0` | + +Anmerkung Unit-Gate: auf diesem Rechner lauscht der colima-Portforward auf 54322 mit +einem fremden Datenbestand; die Integrationssuiten liefen deshalb mit ausdrücklich +unerreichbarer `EASYTREE_TEST_DB_URL` im dokumentierten `mode=local`-Skip (CI-Parität: +der Pflichtjob `unit-tests` hat ebenfalls keine erreichbare DB; `db-gates` fährt die +Suiten `mode=required` gegen den echten Stack). + +## Gegenmutationen (ausgeführt, gemessen, zurückgenommen) + +| # | Mutation | Erwartet rot | Gemessen | +| --- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------- | +| 1 | `lib/wochenraster.ts`: Tageszuordnung über das UTC-Datum (`startUtc.slice(0,10)`) statt `localBusinessDate` | „ordnet einen Einsatz dem Kalendertag seines Beginns in der Organisationszone zu" | 1 failed / 12 passed | +| 2 | `lib/wochenraster.ts`: `ausserhalb`-Zweig zu `continue` (Einsatz verschluckt) | „verschluckt keinen Einsatz ausserhalb der Woche" | 1 failed / 12 passed | +| 3 | `lib/wochenraster.ts`: Tagessortierung nur über die Id | „sortiert einen Tag nach Beginn — gegen die Id-Reihenfolge" | 1 failed / 12 passed | +| 4 | `planning-window-view.tsx`: Inspector `useState(true)` (anfangs offen) | „ist anfangs geschlossen …" | 4 failed / 6 passed | +| 5 | `planning-window-view.tsx`: Ausloeser unbedingt als `PrimaryAction` | CTA-Exklusivität in `dispositionswerkbank.test.tsx` UND `planungs-werkbank.test.tsx` („GENAU EIN … CTA") | 2 failed (je Datei 1) | + +Jede Mutation wurde per Sicherungskopie eingespielt und byte-identisch zurückgenommen +(`diff` leer; die Dateien waren zum Zeitpunkt der Mutationen uncommittet, ein +`git diff`-Beleg existiert deshalb nicht — Sicherungskopie-Verfahren nach +Projektgedächtnis `git-checkout-reverts-to-commit-not-worktree`). + +## Browser-Evidenz (aus der real ausgeführten auth-journey, lokaler Lauf) + +Alle PNGs stammen aus `apps/web/test-results/auth-journey/` des grünen Laufs — keine +Mockups, keine statischen Designbilder. + +| Datei | Zeigt | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `11-werkbank-planung-1440.png` / `…-1920.png` | Dispositionswerkbank bei 1440/1920: Toolbar, Wochenachse Mo–So, Einsatzkarte am Montag, Entwurfszustand, genau ein primärer CTA | +| `04-planung-entwurf.png` | Entwurfszustand (Badge + Text + gestrichelte Karten) | +| `12-werkbank-inspector-1440.png` | Erstellungs-Inspector offen, Fokus im ersten Formularfeld, versionslose Woche ehrlich als „Keine Version" | +| `13-werkbank-einsatz-serverbestaetigt.png` | Serverbestätigte Karte am Dienstag 18.08. nach realem `POST /planung/einsaetze` (201), Formular geleert, Erfolgsmeldung | +| `05-planung-veroeffentlicht.png` | Serverantwort der Veröffentlichung (Banner ohne Id im Text; die Versions-Id liegt in `data-published-version-id`); der Kopf zeigt noch den Moment VOR dem Read-through — die Reload-Zusicherung direkt danach misst `data-stand="veroeffentlicht"` | +| `06-planung-zweiter-kontext.png` | Zweiter Browserkontext, dieselbe veröffentlichte Versions-Id | +| `04-feld-shell-320.png` / `05-feld-shell-375.png` | Feld-Shell-Grenze unverändert | + +Barrierefreiheit im Lauf: axe (wcag2a/2aa/21a/21aa/best-practice) bei 1440×900, +1920×1080 und 720×450 (200-%-Äquivalent) = 0 Verstöße; sichtbarer Fokus je tabbarem +Element bei 1440 und 1920; genau eine `main`-Landmark; ≤ 1 primärer CTA; horizontaler +Überlauf ≤ 1 px. Der automatisierte 720-px-Reflow ist NICHT identisch mit echtem +Browserzoom; der menschliche 200-%-Check bleibt Reviewpunkt des PO. + +## Serverwahrheit im Lauf + +- UI-Create (neu, Schritt 9c1b): `POST /api/v1/planung/einsaetze` → 201, Karte erst nach + Read-through, Reload zeigt dieselbe Server-Id, Woche W34 wird `entwurf`. +- Publish (Schritt 9d): veröffentlichte Versions-Id == vorher angezeigte Entwurfs-Id + (`…e251`), Reload → `data-stand="veroeffentlicht"`, Publish-Knopf weg, zweiter + Browserkontext mit frischem Login sieht dieselbe Id, API-Wiederholung → 409 + `already-published`. +- Negativ: B ohne Mitgliedschaft → `planung-org-erforderlich` + Publish-POST 403; + member ohne `planning.read` → `planung-forbidden` + `GET /planung/fenster` 403; + ohne `costs.read` keine Kosten-Chunks; PostgREST-Angriffe unverändert abgeriegelt. + +## PO-Review-Reparatur (31.08.2026, zweiter Durchgang) + +Reparatur-Slice vor der erneuten visuellen PO-Abnahme. Vier UI-Findings, kein +fachlicher Eingriff; alle Screenshots dieses Pakets wurden aus einem frischen +gruenen `auth-journey`-Lauf NACH der Reparatur regeneriert. + +| Finding | Reparatur | +| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 — Planversions-UUID sichtbar („Zuletzt veroeffentlicht: ") und roher ISO-Zeitstempel in der Publish-Bestaetigung | Sichtbarer Text traegt keine Id und keinen Instant mehr; die echten Server-Ids stehen unveraendert in `data-source-version-id` / `data-published-version-id`, der Publish-Instant neu in `data-published-at-utc` | +| R2 — doppelte dominante Statusmarke (Abzeichen der Wochenansicht UND `StatusBadge` in `planung-stand-marke`) | Genau EIN sichtbares `StatusBadge` (Wochenansicht); `planung-stand-marke` bleibt als unsichtbarer Seam (Testanker, Screenreader-Text, `data-stand`) erhalten | +| R3 — Einsatzformular als ungestylte Browser-Defaults | Formular traegt Basisdesign-v2-Tokens (Feldgruppen, Beginn/Ende nebeneinander, Meldungs-Toene); Semantik, Testanker, Validierung und Zeitzonenlogik unveraendert | +| R4 — ASCII-Formen im sichtbaren Text (`Veroeffentlicht`, `Ausserhalb`, `fuer`) | Sichtbare Texte auf korrektes Deutsch; technische Werte (`data-stand="veroeffentlicht"`, Testids, Dateinamen) unveraendert | + +Neuer Guard: `apps/web/test/werkbank-oberflaechenguards.test.tsx` (UUID-/Instant- +Leck ueber `textContent` des gesamten Baums, Seam-Erhalt, Badge-Zaehlung). +Gegenmutationen ausgefuehrt und byte-identisch zurueckgenommen +(Sicherungskopie-Verfahren, Dateien waren uncommittet): + +| # | Mutation | Gemessen | +| --- | ---------------------------------------------------------------------------------- | ---------------------------------- | +| 1 | Sichtbare Zeile `Zuletzt veroeffentlicht: ${publishedVersionId}` wiederhergestellt | 2 failed / 3 passed (UUID-Guards) | +| 2 | Zweites sichtbares Badge in `planung-stand-marke` wiederhergestellt | 2 failed / 3 passed (Badge-Guards) | + +Gemessene Laeufe dieses Durchgangs (dieselben Befehle wie oben): Format exit 0; +`turbo run lint typecheck --force` = 16 successful / Cached: 0; +`turbo run test --force --env-mode=loose` (Test-DB unerreichbar, `mode=local`-Skip +wie im Pflichtjob `unit-tests`) = 10 successful / Cached: 0 — Web direkt: +41 Dateien / 479 Tests; `turbo run build --force` (ohne `EASYTREE_API_PROXY_TARGET`) += 6 successful / Cached: 0; web-smoke Playwright = 29 passed; auth-journey lokal +(frischer `db reset`, echte GoTrue-Identitaet) = 5 passed, Teardown `restzeilen=0`. +`read-through` und `db-gates` sind lokal nicht CI-aequivalent reproduzierbar +(UNVERIFIED_LOCAL) — der ausgefuehrte CI-Lauf am PR-Head ist dafuer die Evidenz. + +Hinweis Datums-/Zeitfelder: die nativen `date`/`time`-Eingaben folgen der +Browsersprache; die `mm/dd/yyyy`-Platzhalter in den Screenshots stammen aus dem +en-US-Chromium des Playwright-Laufs, ein deutscher Browser zeigt TT.MM.JJJJ. +Massgeblich bleibt die Organisationszeitzone (`zuIntervall`, unveraendert). diff --git a/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md b/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md new file mode 100644 index 00000000..5c05ea92 --- /dev/null +++ b/docs/evidence/2026-08-31-eyt-147-slice-1/ui-parity-matrix.md @@ -0,0 +1,54 @@ +# EYT-147 Slice 1 — UI-Parity-Matrix + +> **Stand:** 31.08.2026, Europe/Berlin +> **Baseline-Head:** `f8e96e4ceb4f00ae5f5ac777c6eb47aa8f766d6f` (`origin/master`, Merge von PR #100) +> **Quelle der Operationen:** `packages/contracts/openapi/v1.json` am Baseline-Head, abgeglichen +> gegen die Routen-Wahrheit `apps/api/test/openapi-route-conformance.test.ts` +> (`NOT_YET_IMPLEMENTED`). Nur belegte Funktionen stehen als REAL. + +## Methode + +Jede Vertragsoperation bekommt genau eine Zeile. `Server` sagt, ob am Baseline-Head eine +registrierte Route existiert (REAL) oder die Operation in `NOT_YET_IMPLEMENTED` steht +(NICHT IMPLEMENTIERT). `UI heute` beschreibt die reale Oberfläche am Baseline-Head. +`Slice 1` ist die Entscheidung dieses Inkrements. Ein „Nicht-UI-Systempfad" ist begründet, +nicht behauptet. + +## Admin-/Werkbank-Operationen + +| Operation | Server | UI heute (Baseline) | Slice 1 | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /planung/fenster` — Planungsfenster lesen | REAL (`PlanningController`) | REAL — `/planung` lädt die Woche, flache Einsatzliste | **REDESIGN** — Woche als räumliche Tagesachse (Mo–So), Einsätze als Karten am realen Tag | +| `POST /planung/einsaetze` — Einsatz anlegen | REAL | REAL — Formular unten in der Karte, immer offen | **INTEGRATE** — dasselbe Formular, unverändert validiert, im seitlichen Inspector; Öffnung über `+ Einsatz anlegen` | +| `POST /planung/versionen` — Plan veröffentlichen | REAL | REAL — `PlanningPublishAction` mit Serverwahrheit | **INTEGRATE** — unverändert; als primäre Aktion des Entwurfszustands positioniert | +| `POST /planung/entwuerfe/validierung` — Entwurf prüfen | REAL | KEINE — kein UI-Aufrufer | **Nicht-UI-Systempfad, begründet:** Die Prüfung läuft fachlich identisch im Publish-Pfad; die Publish-Ablehnungen (409-URNs) sind die sichtbare Antwort. Ein eigener „Prüfen"-Knopf ohne PO-Entscheid wäre eine zweite gleichgewichtete Aktion neben der einen Primäraktion. Bleibt EYT-147-Restumfang. | +| Assignment **bearbeiten** | **EXISTIERT NICHT** — kein Update-Command im Port (`packages/contracts/src/planning/gateway.ts`: nur `getPlanningWindow`, `validateDraft`, `createAssignment`, `publishPlan`) | keine | **CAPABILITY_GAP** — wird nicht erfunden. Slice 1 = anzeigen → anlegen → bestätigen → Entwurf → veröffentlichen → Reload. Karten sind nicht klickbar-editierbar. | +| Assignment **löschen** | EXISTIERT NICHT (kein Command, DB: kein `delete`-Grant, „born a draft") | keine | **CAPABILITY_GAP** — nicht erfunden. | +| Drag&Drop-Umdisposition | EXISTIERT NICHT (kein Move-/Update-Command) | keine | **CAPABILITY_GAP** — kein Drag&Drop ohne idempotenten Server-Command (Auftrag §11). | +| Konfliktberechnung/-anzeige je Karte | Vertrag liefert `PlanningConflictDto` nur als **Publish-/Validierungsantwort**, nicht im `PlanningWindow` | Publish-Ablehnung `blocking-conflict` sichtbar | **PRESERVE** — Konflikt bleibt Publish-Antwort auf Planebene; kein erfundener Karten-Konfliktstatus. | +| Entwurf/Veröffentlicht-Status | REAL — Vertrag trägt Status **nur auf Planversionsebene** (`sourceVersion.state`, `publishedVersionId`), nicht je Assignment | REAL — Textzeile + `StatusBadge` | **REDESIGN** — Status prominent auf Wochen-/Planebene (Text + Glyphe + Farbe); KEIN erfundener Einzelkarten-Status. | +| `GET /auth/session` / `POST /auth/login` / `POST /auth/logout` | REAL | REAL — `/anmelden`, Shell, `SessionProvider` | **PRESERVE** | +| `GET /kosten/mitarbeiter` | REAL | REAL — `/kosten/stundensaetze` | **PRESERVE** (nur mit `costs.read`) | +| `GET /kosten/stundensaetze/{employeeId}` | REAL | REAL — Satzverwaltung | **PRESERVE** | +| `POST /kosten/stundensaetze` | REAL | REAL — Satzformular | **PRESERVE** | +| `GET /kosten/planversionen` | REAL | REAL — Kostenfläche | **PRESERVE** | +| `GET /kosten/planversionen/{id}/baustellen` | REAL | REAL — Kostenfläche | **PRESERVE** | +| `POST /kosten/snapshots` | REAL | REAL — Kostenfläche | **PRESERVE** | +| `GET /kosten/snapshots/{snapshotId}` | REAL | REAL — Kostenfläche | **PRESERVE** | + +## Mitarbeiter-Operationen (nicht Slice 1, nicht Werkbank) + +| Operation | Server | Einordnung | +| ------------------------------ | ------------------------------------------- | ------------------------------------------------------------ | +| `GET /einsatz/plan` | NICHT IMPLEMENTIERT (`NOT_YET_IMPLEMENTED`) | EYT-81; Feld-Shell zeigt den ehrlichen Leerzustand (EYT-113) | +| `POST /einsatz/bestaetigungen` | NICHT IMPLEMENTIERT | EYT-14/EYT-81 | +| `POST /einsatz/ablehnungen` | NICHT IMPLEMENTIERT | EYT-14/EYT-81 | +| `POST /einsatz/zeiten/start` | NICHT IMPLEMENTIERT | EYT-14 | +| `POST /einsatz/zeiten/stopp` | NICHT IMPLEMENTIERT | EYT-14 | + +## Bewusst NICHT sichtbar gemacht (Reality Boundaries, Auftrag §10) + +Wetter, Equipment, Arbeitszeiterfassung, Mitarbeiter-Bestätigung/-Ablehnung, +Routenoptimierung, Solver, Ressourcenplanung, neue Kostenberechnungen: für keine dieser +Fähigkeiten existiert am Baseline-Head ein Server-/Domain-Pfad — sie erscheinen nicht in +der Werkbank, auch nicht als Platzhalter. diff --git a/supabase/migrations/20260901101624_0019_worksite_days.sql b/supabase/migrations/20260901101624_0019_worksite_days.sql new file mode 100644 index 00000000..061fba2c --- /dev/null +++ b/supabase/migrations/20260901101624_0019_worksite_days.sql @@ -0,0 +1,774 @@ +-- Migration 0019_worksite_days — Datenfundament der baustellenzentrierten Planung +-- (EYT-125 Architektur, EYT-147 Slice, Meilenstein M1) +-- +-- ANLASS +-- --------------------------------------------------------------------------- +-- Die Planung war bisher personenzentriert: eine Zuweisung hing an einer Woche +-- und einer Person, ein BAUSTELLENTAG existierte nirgends als Gegenstand. Damit +-- liess sich weder ein Tag als Ganzes umplanen noch eine Tagesbesetzung +-- revisionssicher fortschreiben. Diese Migration legt das Fundament dafuer an — +-- und ausschliesslich das Fundament: keine Taetigkeiten, keine Ressourcen, kein +-- Tagesstatus, kein Zeitraum, kein Generator. +-- +-- ZWEI OBJEKTE, ZWEI VERSCHIEDENE LEBENSDAUERN +-- --------------------------------------------------------------------------- +-- `worksite_days` ist die IDENTITAET: eine Zeile je (Organisation, Baustelle, +-- lokaler Tag), versionsuebergreifend stabil. Sie ueberlebt jede Planversion und +-- traegt deshalb weder Zeiten noch einen Publish-Marker. +-- +-- `worksite_day_configurations` ist die REVISION: der Planungsstand genau dieses +-- Tages in genau einer Planversion. Wird eine Version kopiert, entsteht eine neue +-- Konfiguration auf DERSELBEN Identitaet. +-- +-- KEINE ZWEITE PUBLISH-WAHRHEIT +-- --------------------------------------------------------------------------- +-- `plan_versions.published_at` bleibt die einzige Veroeffentlichungsmarke. Keine +-- Tabelle dieser Migration traegt einen eigenen. Wo die Unveraenderlichkeit einer +-- veroeffentlichten Revision durchzusetzen ist, wird der Zustand der ELTERNZEILE +-- gelesen — nicht ein lokal gespiegelter Marker, der auseinanderlaufen koennte. +-- +-- APPEND-ONLY, KEIN BACKFILL +-- --------------------------------------------------------------------------- +-- Diese Migration aendert keine bestehende Zeile. `assignments` bekommt eine +-- NULLABLE Spalte; Bestandszuweisungen bleiben ohne Tageskonfiguration gueltig +-- und behalten ihre Identitaet. Es gibt keinen Cutover-Schritt hier. +-- +-- ROLLBACK (vorwaerts, als NEUE 0020 — 0019 wird nach dem Merge nie editiert) +-- --------------------------------------------------------------------------- +-- alter table public.assignments drop constraint assignments_worksite_day_configuration_fk; +-- drop index public.assignments_worksite_day_configuration_idx; +-- alter table public.assignments drop column worksite_day_configuration_id; +-- revoke insert on table public.assignments from authenticated; +-- grant insert (org_id, plan_version_id, employee_id, worksite_id, +-- starts_at_utc, ends_at_utc) on table public.assignments to authenticated; +-- drop function app.remove_assignment_from_worksite_day(uuid, uuid); +-- drop function app.read_idempotency_result(text, text); +-- drop function app.lock_week_draft(text); +-- drop table public.worksite_day_configurations; +-- drop table public.worksite_days; +-- drop function app.reject_worksite_day_configuration_change_in_published_plan(); +-- drop function app.assignment_belongs_to_worksite_day_configuration(); +-- -- REIHENFOLGE: erst die Policy, dann die Spalte. Die 0019-Fassung von +-- -- idempotency_records_insert_in_org nennt result_payload in ihrer +-- -- with-check-Klausel und haengt damit an der Spalte; umgekehrt bricht der +-- -- Rollback mit "cannot drop column result_payload … policy … depends on it" +-- -- ab (gemessen, PostgreSQL 17.6, Transaktion zurueckgerollt). +-- drop policy idempotency_records_insert_in_org on public.idempotency_records; +-- create policy idempotency_records_insert_in_org on public.idempotency_records +-- for insert to authenticated with check (org_id in (select app.user_org_ids())); +-- alter table public.idempotency_records +-- drop constraint idempotency_records_worksite_day_ops_need_payload, +-- drop constraint idempotency_records_result_payload_is_object, +-- drop column result_payload; +-- grant select on table public.idempotency_records to authenticated; +-- +-- Der Rollback verliert die in `result_payload` gespeicherten Erstantworten. Das +-- ist der einzige Datenverlust dieser Umkehr und betrifft ausschliesslich die +-- beiden neuen Vorgaenge, die es vor dieser Migration nicht gab. + +-- --------------------------------------------------------------------------- +-- 1. IDENTITAET: eine Zeile je (Organisation, Baustelle, lokaler Tag) +-- --------------------------------------------------------------------------- +-- Keine Planungsdaten, keine Zeiten, kein Publish-Marker, versionsuebergreifend +-- stabil (R-01). Die Ortszone der Organisation entscheidet, welcher reale Tag +-- gemeint ist — sie wird hier NICHT materialisiert, weil das ein zweiter Ort +-- fuer eine Wahrheit waere, die `organizations.time_zone` bereits traegt. +create table public.worksite_days ( + id uuid not null default gen_random_uuid(), + org_id uuid not null references public.organizations (id) on delete cascade, + worksite_id uuid not null, + local_date date not null, + created_at timestamptz not null default now(), + primary key (id), + unique (id, org_id), + -- Die Identitaetsgarantie (I-4): derselbe reale Baustellentag ist immer + -- dieselbe Zeile. Ohne sie waere "stabile Identitaet" eine Absichtserklaerung. + unique (org_id, worksite_id, local_date), + -- Tenantgebunden: eine baustellenfremde Referenz scheitert auf FK-Ebene + -- (23503), unabhaengig von RLS. `restrict`, weil eine Baustelle mit geplanten + -- Tagen nicht stillschweigend verschwinden darf. + foreign key (worksite_id, org_id) references public.worksites (id, org_id) on delete restrict +); +-- EYT-120 ergaenzt hier spaeter additiv `continuous_worksite_plan_id uuid null`. + +comment on table public.worksite_days is + 'Stabile Identitaet eines Baustellentages je Organisation, Baustelle und lokalem Datum (EYT-147). Traegt bewusst KEINE Zeiten und KEINEN Publish-Marker: sie ueberlebt jede Planversion, waehrend der Planungsstand in worksite_day_configurations liegt.'; + +comment on column public.worksite_days.local_date is + 'Lokaler Geschaeftstag in der Zeitzone der Organisation. Kein Zeitstempel — welcher UTC-Bereich das ist, entscheidet organizations.time_zone zur Lesezeit.'; + +-- --------------------------------------------------------------------------- +-- 2. REVISION: der Planungsstand dieses Tages in genau einer Planversion +-- --------------------------------------------------------------------------- +create table public.worksite_day_configurations ( + id uuid not null default gen_random_uuid(), + org_id uuid not null references public.organizations (id) on delete cascade, + worksite_day_id uuid not null, + plan_version_id uuid not null, + -- Nebenlaeufigkeits-Token der TAGESIDENTITAET, je Revision materialisiert und + -- beim Kopieren uebernommen. KEINE Zeiten, KEIN published_at. + lock_version integer not null default 0 check (lock_version >= 0), + created_at timestamptz not null default now(), + primary key (id), + unique (id, org_id), + -- I-3: ein Baustellentag hat je Planversion hoechstens EINEN Stand. + unique (plan_version_id, worksite_day_id), + foreign key (worksite_day_id, org_id) references public.worksite_days (id, org_id), + -- Kaskade NUR hier: verschwindet ein unveroeffentlichter Entwurf, geht sein + -- Tagesstand mit. Die IDENTITAET bleibt (siehe FK oben, ohne Kaskade). + foreign key (plan_version_id, org_id) + references public.plan_versions (id, org_id) on delete cascade +); + +comment on table public.worksite_day_configurations is + 'Revisionsgebundener Planungsstand eines Baustellentages in genau einer Planversion (EYT-147). Traegt KEINEN eigenen Publish-Marker: autoritativ ist plan_versions.published_at, das die Guards dieser Migration an der Elternzeile lesen.'; + +comment on column public.worksite_day_configurations.lock_version is + 'Nebenlaeufigkeits-Token der Tagesidentitaet, je Revision materialisiert und beim Kopieren einer Planversion uebernommen. Genau EINE Stale-Wahrheit je Tag (R-20).'; + +create index worksite_day_configurations_version_idx + on public.worksite_day_configurations (plan_version_id); +create index worksite_day_configurations_day_idx + on public.worksite_day_configurations (worksite_day_id); + +-- --------------------------------------------------------------------------- +-- 3. RLS und Grants nach dem 0017-Muster +-- --------------------------------------------------------------------------- +-- Spaltenweise Grants, Kanal und Recht in der Policy. KEIN delete-Grant auf +-- beiden Tabellen: dieser Slice hat kein Loesch-Command, und ein Recht ohne +-- Verbraucher ist reine Angriffsflaeche (0017). Entsprechend gibt es auch keine +-- delete-Policy — sonst genuegte spaeter ein `grant`, um den Weg unbemerkt zu +-- oeffnen. +revoke all on table public.worksite_days, public.worksite_day_configurations + from anon, authenticated; + +grant select on table public.worksite_days, public.worksite_day_configurations to authenticated; + +grant insert (org_id, worksite_id, local_date) on table public.worksite_days to authenticated; + +grant insert (org_id, worksite_day_id, plan_version_id, lock_version) + on table public.worksite_day_configurations to authenticated; + +grant update (lock_version) on table public.worksite_day_configurations to authenticated; + +alter table public.worksite_days enable row level security; +alter table public.worksite_days force row level security; +alter table public.worksite_day_configurations enable row level security; +alter table public.worksite_day_configurations force row level security; + +create policy worksite_days_select_in_org on public.worksite_days + for select to authenticated + using (org_id in (select app.user_org_ids())); + +-- `app.is_runtime_channel()` steht zuerst, wie in 0015 und 0017: PostgreSQL +-- garantiert keine Auswertungsreihenfolge, aber die Zeile sagt dem Lesenden, +-- welche Bedingung die tragende ist. +create policy worksite_days_insert_in_org on public.worksite_days + for insert to authenticated + with check ( + app.is_runtime_channel() + and org_id in (select app.user_org_ids()) + and app.has_permission(org_id, 'planning.write') + ); + +create policy worksite_day_configurations_select_in_org on public.worksite_day_configurations + for select to authenticated + using (org_id in (select app.user_org_ids())); + +create policy worksite_day_configurations_insert_in_org on public.worksite_day_configurations + for insert to authenticated + with check ( + app.is_runtime_channel() + and org_id in (select app.user_org_ids()) + and app.has_permission(org_id, 'planning.write') + ); + +-- Den ENTWURFSZUSTAND prueft der Trigger in Block 5, nicht diese Policy — +-- dieselbe Arbeitsteilung wie beim assignments-INSERT (0016/0017): die Policy +-- beantwortet "darf dieser Kanal mit diesem Recht ueberhaupt schreiben", die +-- Fachregel steht im Trigger. +create policy worksite_day_configurations_update_in_org on public.worksite_day_configurations + for update to authenticated + using ( + app.is_runtime_channel() + and org_id in (select app.user_org_ids()) + and app.has_permission(org_id, 'planning.write') + ) + with check ( + app.is_runtime_channel() + and org_id in (select app.user_org_ids()) + and app.has_permission(org_id, 'planning.write') + ); + +-- --------------------------------------------------------------------------- +-- 4. Aufnahme-Guard: eine veroeffentlichte Version nimmt nichts mehr auf +-- --------------------------------------------------------------------------- +-- Wiederverwendung der generischen 0016-Fassung: sie liest ausschliesslich +-- `new.plan_version_id` und `new.org_id` und ist damit auf jede Kindtabelle von +-- `plan_versions` anwendbar. Ihr Fehlertext spricht von "Zuweisung"; das ist der +-- Preis der Wiederverwendung und wird hier benannt statt durch eine zweite, +-- fast gleiche Funktion vermieden. +create trigger worksite_day_configurations_reject_published_plan + before insert or update of plan_version_id on public.worksite_day_configurations + for each row execute function app.reject_assignment_in_published_plan(); + +-- --------------------------------------------------------------------------- +-- 5. Unveraenderlichkeit OHNE lokalen Marker +-- --------------------------------------------------------------------------- +-- ZWINGEND `security definer` — gemessene 0015-Lektion (CI-Lauf 30862744360): +-- ein INVOKER-`for share` prueft zusaetzlich die USING-Klausel der UPDATE-Policy +-- von `plan_versions`, und die verlangt `planning.publish`. Eine Sitzung mit nur +-- `planning.write` — das Recht dieser Tabellen — faende die veroeffentlichte +-- Elternzeile NICHT, `v_published_at` bliebe NULL und der Guard liesse die +-- Mutation still durch. +create function app.reject_worksite_day_configuration_change_in_published_plan() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_published_at timestamptz; +begin + -- (1) DIE ZUGEHOERIGKEIT IST UNVERAENDERLICH. + -- `worksite_day_id` und `plan_version_id` sind die Identitaet dieser Zeile, + -- nicht ihr Inhalt. Ein Umhaengen wuerde I-1 (Block 7) NACHTRAEGLICH + -- brechen: der Zugehoerigkeitstrigger auf `assignments` feuert nur bei + -- INSERT/UPDATE DORT und laeuft bei einer Aenderung HIER nie erneut. + -- Review-MESSUNG (PostgreSQL 17.6, lokaler Stack, zurueckgerollt): ohne + -- diesen Riegel liess `update … set plan_version_id = ` + -- eine Konfiguration auf Planversion B zeigen, waehrend ihre Zuweisungen + -- weiter auf A zeigten (`gleich=f`), und `update … set worksite_day_id = + -- ` erzeugte `assignment.worksite_id <> + -- worksite_days.worksite_id`. Beide Netze liessen es durch: der + -- Aufnahme-Guard sah einen Entwurf, dieser Guard nur den Publish-Stand. + -- + -- Das Spaltenrecht allein genuegt hier NICHT als Riegel. `authenticated` + -- besitzt zwar nur `update (lock_version)`, aber 0017 hat fuer genau diese + -- Konstruktion die Regel aufgestellt: ein Weg, der nur durch ein fehlendes + -- Grant versperrt ist, geht mit dem naechsten `grant` unbemerkt wieder auf. + if tg_op = 'UPDATE' + and (new.worksite_day_id <> old.worksite_day_id + or new.plan_version_id <> old.plan_version_id) then + raise exception 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden' + using errcode = '23514'; + end if; + + -- (2) VEROEFFENTLICHUNGSSTAND DER ELTERNZEILE. + -- BEWUSST OHNE Sichtbarkeitsfilter — anders als der Aufnahme-Guard aus + -- 0016. Dort ist `new.plan_version_id` ein vom Aufrufer GEWAEHLTER Wert, + -- und der Filter verhindert, dass die Fehlermeldung die Existenz einer + -- fremden Planversion verraet; die Entscheidung faellt dann beim + -- Fremdschluessel (23503). Hier stammt `old.plan_version_id` aus einer + -- Zeile, die der Aufrufer bereits in der Hand hat — es gibt keine fremde + -- Existenz mehr zu verraten, und der Filter waere reiner Schaden. + -- + -- Review-MESSUNG (PostgreSQL 17.6, lokaler Stack, zurueckgerollt): MIT + -- Filter erzeugte eine Sitzung mit ORG-FREMDER Identitaet in + -- `request.jwt.claims` denselben `not found` wie die Loeschkaskade — und + -- der DELETE-Zweig liess sie durch: `DELETE 1`, die Tageskonfiguration + -- einer VEROEFFENTLICHTEN Planversion war weg. Dieselbe Anweisung ohne + -- Claims wurde korrekt mit 23514 abgewiesen. Der Bestandsguard + -- `app.reject_published_row_change` (0010) hat diese Luecke nicht, weil er + -- `old.published_at` an der Zeile SELBST liest und damit + -- identitaetsunabhaengig ist. Ohne Filter ist `not found` hier wieder + -- eindeutig: die Elternzeile existiert wirklich nicht mehr. + select pv.published_at + into v_published_at + from public.plan_versions pv + where pv.id = old.plan_version_id + and pv.org_id = old.org_id + for share; + + if not found then + -- Review-MESSUNG: bei einer Loeschkaskade ist die Elternzeile im + -- BEFORE-DELETE des Kindes BEREITS entfernt — „nicht gefunden" ist dort der + -- NORMALFALL. Eine fail-closed-Variante machte gemessen schon das Loeschen + -- eines UNVEROEFFENTLICHTEN Entwurfs mit Tageskonfiguration unmoeglich und + -- haette die Aufraeumpfade der Bestandssuiten gebrochen. Die Entscheidung + -- gehoert dort dem Fremdschluessel und der Kaskade, nicht dieser Regel. Die + -- Unveraenderlichkeit VEROEFFENTLICHTER Zeilen bleibt gewahrt, weil eine + -- veroeffentlichte `plan_versions`-Zeile nach 0010 selbst unloeschbar ist + -- und ihre Kaskade damit nie anlaeuft. + -- + -- Bei UPDATE kann es per Konstruktion keine Kaskade geben (kein + -- `on update cascade`), und der Fremdschluessel garantiert die Elternzeile — + -- ein `not found` ist dort ein unerklaerter Zustand und wird benannt. + if tg_op = 'DELETE' then + return old; + end if; + raise exception 'Planversion der Tageskonfiguration nicht aufloesbar' + using errcode = '23514'; + end if; + + if v_published_at is not null then + raise exception 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich' + using errcode = '23514'; + end if; + + if tg_op = 'DELETE' then + return old; + end if; + return new; +end +$$; + +comment on function app.reject_worksite_day_configuration_change_in_published_plan() is + 'Zwei Regeln fuer worksite_day_configurations (EYT-147). (1) Die Zugehoerigkeit ist unveraenderlich: worksite_day_id und plan_version_id lassen sich nicht umhaengen, sonst braeche I-1 nachtraeglich, weil der Zugehoerigkeitstrigger auf assignments dabei nie erneut feuert. (2) Die Konfiguration einer veroeffentlichten Planversion ist unveraenderlich — ohne zweiten Publish-Marker, der Zustand wird an der Elternzeile gelesen. security definer aus demselben Grund wie 0015: ein Invoker-for-share pruefte die UPDATE-Policy von plan_versions mit und saehe die Zeile ohne planning.publish nicht. Die Elternabfrage traegt BEWUSST KEINEN Sichtbarkeitsfilter: old.plan_version_id stammt aus einer Zeile, die der Aufrufer schon haelt, es gibt also keine fremde Existenz zu verraten — mit Filter erzeugte eine org-fremde Identitaet denselben not-found wie die Loeschkaskade und konnte gemessen eine veroeffentlichte Tageskonfiguration loeschen. Fail-open ausschliesslich fuer DELETE, weil dort die Loeschkaskade den Normalfall "nicht gefunden" erzeugt.'; + +revoke all on function app.reject_worksite_day_configuration_change_in_published_plan() + from public; + +create trigger worksite_day_configurations_immutable_when_published + before update or delete on public.worksite_day_configurations + for each row + execute function app.reject_worksite_day_configuration_change_in_published_plan(); + +-- --------------------------------------------------------------------------- +-- 6. Assignments: Unterordnung unter die REVISION (R-26) +-- --------------------------------------------------------------------------- +-- Nullable fuer Bestandszeilen. Kein Backfill, keine Aenderung der bisherigen +-- Assignment-Identitaet. +alter table public.assignments add column worksite_day_configuration_id uuid; + +comment on column public.assignments.worksite_day_configuration_id is + 'Tageskonfiguration, unter der diese Zuweisung geplant wurde (EYT-147). NULL fuer Bestandszeilen aus der personenzentrierten Planung; diese bleiben unveraendert gueltig.'; + +-- NO ACTION (Default). Review-MESSUNG (PostgreSQL 17.6, Replika dieser +-- Topologie): RESTRICT verhielte sich hier identisch; der einzige dokumentierte +-- Unterschied ist Deferrierbarkeit. +alter table public.assignments add constraint assignments_worksite_day_configuration_fk + foreign key (worksite_day_configuration_id, org_id) + references public.worksite_day_configurations (id, org_id); + +create index assignments_worksite_day_configuration_idx + on public.assignments (worksite_day_configuration_id); + +-- Der 0017-Grantumfang waechst um GENAU eine Spalte. `id` bleibt draussen (0012 +-- A4), `update` und `delete` bleiben entzogen, und es entsteht KEINE zweite +-- Schreibpolicy auf `assignments` — die 0017-Fassung gilt unveraendert weiter. +revoke insert on table public.assignments from authenticated; +grant insert (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, + starts_at_utc, ends_at_utc) + on table public.assignments to authenticated; + +-- --------------------------------------------------------------------------- +-- 7. Strukturelle Zugehoerigkeit (I-1) +-- --------------------------------------------------------------------------- +-- Baustelle und Planversion der Zuweisung stimmen mit denen ihrer +-- Tageskonfiguration ueberein. Der Fremdschluessel allein saehe das nicht: er +-- prueft die Existenz der Konfiguration im selben Mandanten, nicht die +-- Uebereinstimmung dreier weiterer Werte. +-- +-- Bewusst INVOKER (kein `security definer`): die Funktion liest nur Zeilen, die +-- der Aufrufer ohnehin sehen darf, und ihr `if not found then return new` gibt +-- die Existenzfrage ausdruecklich an den Fremdschluessel ab (23503). Ein definer +-- waere hier eine unnoetige Rechteerhoehung. +create function app.assignment_belongs_to_worksite_day_configuration() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + v_worksite_id uuid; + v_plan_version_id uuid; +begin + -- Legacy-/Bestandspfad: eine Zuweisung ohne Tageskonfiguration ist gueltig. + if new.worksite_day_configuration_id is null then + return new; + end if; + + select d.worksite_id, c.plan_version_id + into v_worksite_id, v_plan_version_id + from public.worksite_day_configurations c + join public.worksite_days d + on d.id = c.worksite_day_id and d.org_id = c.org_id + where c.id = new.worksite_day_configuration_id + and c.org_id = new.org_id; + + -- Existenz und Sichtbarkeit gehoeren dem Fremdschluessel (23503), nicht + -- dieser Regel. + if not found then + return new; + end if; + + if v_worksite_id <> new.worksite_id or v_plan_version_id <> new.plan_version_id then + raise exception 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration' + using errcode = '23514'; + end if; + + return new; +end +$$; + +comment on function app.assignment_belongs_to_worksite_day_configuration() is + 'Strukturelle Zugehoerigkeit einer Zuweisung zu ihrer Tageskonfiguration (EYT-147, I-1): Organisation, Baustelle und Planversion muessen uebereinstimmen. Bewusst invoker — die Existenzfrage gehoert dem Fremdschluessel.'; + +revoke all on function app.assignment_belongs_to_worksite_day_configuration() from public; + +-- Der Publish-Spiegel aus 0010 (`update published_at`, security definer) +-- passiert diesen Trigger folgenlos: er aendert keine der drei verglichenen +-- Spalten, und der Vergleich bleibt damit wahr. +create trigger assignments_belong_to_worksite_day_configuration + before insert or update on public.assignments + for each row execute function app.assignment_belongs_to_worksite_day_configuration(); + +-- --------------------------------------------------------------------------- +-- 8. FACHLICH GEBUNDENE Entfernen-Grenze (R-13/R-23) +-- --------------------------------------------------------------------------- +-- KEIN allgemeines DELETE-Primitive. 0017 hat `delete` auf `assignments` +-- ersatzlos entzogen; diese Funktion gibt es nicht zurueck, sondern stellt genau +-- einen fachlich gebundenen Vorgang bereit: entferne GENAU DIESE Zuweisung aus +-- GENAU DIESEM Baustellentag. +create function app.remove_assignment_from_worksite_day( + p_assignment_id uuid, + p_worksite_day_configuration_id uuid +) returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_a record; + v_c record; + v_published_at timestamptz; +begin + if not app.is_runtime_channel() then + raise exception 'Nur der Laufzeitkanal darf Zuweisungen aus einem Baustellentag entfernen' + using errcode = '42501'; + end if; + + -- ACHTUNG: unter `security definer` ist dieser Select NICHT RLS-gefiltert; die + -- einzige Mandantengrenze ist der explizite org-Check unten. Praezedenz fuer + -- definer + `app.user_org_ids()` im Rumpf: app.reject_assignment_in_published_plan + -- (0015/0016). Von app.lock_employee_planning (0012, invoker) stammt der + -- andere Teil des Musters: die Organisation kommt aus der ZEILE, nie aus einem + -- Parameter. + select a.org_id, a.plan_version_id, a.published_at, a.worksite_day_configuration_id + into v_a + from public.assignments a + where a.id = p_assignment_id; + + select c.org_id, c.plan_version_id + into v_c + from public.worksite_day_configurations c + where c.id = p_worksite_day_configuration_id; + + -- Fremd und inexistent sind nach aussen ununterscheidbar: EIN Fehler, EIN + -- Wortlaut. Alles andere waere ein Existenzleck ueber fremde Mandanten. + if v_a is null or v_c is null + or v_a.org_id not in (select app.user_org_ids()) + or v_c.org_id <> v_a.org_id + or v_a.worksite_day_configuration_id is distinct from p_worksite_day_configuration_id + or v_a.plan_version_id <> v_c.plan_version_id then + raise exception 'Zuweisung gehoert nicht zu dieser Tageskonfiguration' + using errcode = 'P0002'; + end if; + + if not app.has_permission(v_a.org_id, 'planning.write') then + raise exception 'Kein Planungsrecht' using errcode = '42501'; + end if; + + select pv.published_at + into v_published_at + from public.plan_versions pv + where pv.id = v_a.plan_version_id + and pv.org_id = v_a.org_id + for share; -- Race gegen einen laufenden Publish + + if not found then + -- Konsistent mit Block 5, aber mit umgekehrtem Ausgang: hier ist KEINE + -- Kaskade im Spiel (die Funktion wird direkt aufgerufen, nicht aus einem + -- Trigger), die Elternzeile existiert per Fremdschluessel, und ein stummer + -- Durchlass waere ein Loch in genau der Grenze, die diese Funktion ist. + raise exception 'Planversion der Zuweisung nicht aufloesbar' using errcode = '23514'; + end if; + + if v_published_at is not null or v_a.published_at is not null then + raise exception 'Veroeffentlichte Zuweisung kann nicht entfernt werden' + using errcode = '23514'; + end if; + + delete from public.assignments where id = p_assignment_id; + -- Zweiter Riegel bleibt scharf: `assignments_published_immutable` (0010) feuert + -- auch fuer den definer/Eigentuemer und lehnte eine veroeffentlichte Zeile + -- zusaetzlich mit 23514 ab. +end +$$; + +comment on function app.remove_assignment_from_worksite_day(uuid, uuid) is + 'Entfernt GENAU EINE Zuweisung aus GENAU EINEM Baustellentag (EYT-147, R-13/R-23). Kein allgemeines DELETE-Primitive: Laufzeitkanal, Mandantenbindung aus der Zeile, planning.write, fachliche Zugehoerigkeit und Veroeffentlichungsstand werden in dieser Reihenfolge geprueft. Fremd und inexistent teilen sich einen Fehler, damit keine fremde Existenz durchscheint.'; + +revoke all on function app.remove_assignment_from_worksite_day(uuid, uuid) from public; +grant execute on function app.remove_assignment_from_worksite_day(uuid, uuid) to authenticated; + +-- --------------------------------------------------------------------------- +-- 9. Idempotenz: unveraenderliches Ergebnis des ERSTEN Aufrufs (R-21) +-- --------------------------------------------------------------------------- +alter table public.idempotency_records add column result_payload jsonb; + +alter table public.idempotency_records + add constraint idempotency_records_result_payload_is_object + check (result_payload is null or jsonb_typeof(result_payload) = 'object'); + +-- Pflicht fuer die neuen Vorgaenge in der DATENBANK, nicht nur im Test: ein +-- payloadloser Insert ist damit 23514 statt eines stillen Zustands, in dem der +-- Replay weder die erste Antwort reproduzieren noch ehrlich scheitern koennte. +-- Der Name ist 49 Zeichen lang und damit 14 unter NAMEDATALEN-1 (63) — er wird +-- nicht abgeschnitten. Gemessen, nicht geschaetzt: select length(conname). +alter table public.idempotency_records + add constraint idempotency_records_worksite_day_ops_need_payload + check ( + operation not in ('planning.plan_worksite_day', 'planning.update_worksite_day_team') + or result_payload is not null + ); + +-- Kanalpruefung nachziehen: bis heute prueft die INSERT-Policy nur die +-- Organisation — 0017 liess das stehen, weil kein Verbraucher davon profitierte. +-- Mit `result_payload` speist diese Spalte den Antwortkoerper eines Commands; +-- ohne Kanalriegel koennte jede authenticated-Sitzung mit Org-Mitgliedschaft +-- ueber die Data-API ein Replay-Ergebnis vorschreiben. +-- +-- Additiv und OPERATIONSGEBUNDEN, nicht pauschal (Review-MESSUNG): es gibt VIER +-- Schreiber von `idempotency_records` — planning.create_assignment, +-- planning.publish_plan, costs.create_rate_version und costs.create_cost_snapshot. +-- `costs.create_rate_version` schreibt `employee_rate_versions`, die selbst +-- KEINEN Laufzeitkanal verlangt, und seine Integrationssuite laeuft ueber eine +-- `postgres`-Sitzung. Eine pauschale Kanalpflicht liesse diesen Bestandspfad an +-- RLS scheitern (gemessen: derselbe Insert gelingt mit der Bestands-Policy und +-- scheitert mit der pauschalen Fassung). Den Kanal braucht nur, wer ein +-- `result_payload` schreibt — also genau die beiden neuen Vorgaenge. +drop policy idempotency_records_insert_in_org on public.idempotency_records; +create policy idempotency_records_insert_in_org on public.idempotency_records + for insert to authenticated + with check ( + org_id in (select app.user_org_ids()) + and (result_payload is null or app.is_runtime_channel()) + ); + +-- LESEGRENZE (R5-1). RLS filtert ZEILEN, nicht Spalten: eine Policy-Bedingung +-- koennte den Payload nur verbergen, indem sie die ganze Zeile verschwinden +-- laesst — und selbst dann bliebe er fuer jede Sitzung lesbar, die die +-- Zeilenbedingung erfuellt. Der Payload traegt historische Command-Evidenz +-- (Team, Personen, Intervalle eines Draft-Standes, der spaeter geaendert oder +-- entfernt worden sein kann) und 0012 kennt keine Aufbewahrungsregel. +-- +-- Deshalb ein SPALTENRECHT statt einer Zeilenbedingung — dasselbe Muster, mit +-- dem 0010/0017 die assignments-Schreibrechte begrenzt haben, nur fuer SELECT. +-- Die SELECT-POLICY bleibt woertlich unveraendert (org-weit): der einzige +-- Bestandsleser liest `subject_id, request_fingerprint` +-- (pg-idempotency-store.ts:26-28, gemessen: kein `select *` im Repository), und +-- beide Spalten stehen weiter im Grant. Alle vier Bestandsvorgaenge bleiben +-- damit unberuehrt. +revoke select on table public.idempotency_records from authenticated; +grant select (id, org_id, operation, idempotency_key, subject_id, request_fingerprint, + created_at) + on table public.idempotency_records to authenticated; + +comment on column public.idempotency_records.result_payload is + 'Unveraenderliche Antwort des ERSTEN Aufrufs (EYT-147). Nullable fuer Bestandsvorgaenge, die ihr Ergebnis ueber subject_id zurueckliesen. Inhalt: ausschliesslich Ids, Zeitstempel und Zahlen — niemals Labels oder Namen. ABER ausdruecklich NICHT derselbe Massstab wie audit_events.context: dies ist historische Command-Evidenz und haelt Personen- und Intervallzuordnungen fest, die aus dem aktuellen Draft spaeter entfernt worden sein koennen. Deshalb steht diese Spalte NICHT im SELECT-Grant und ist ausschliesslich ueber app.read_idempotency_result lesbar: nur die beiden Payload-Vorgaenge, nur im Laufzeitkanal, nur mit planning.write UND nur gebunden an die genau eine aktive Organisation der aufrufenden Identitaet, die die Funktion vor der Zeilenauswahl aufloest — und deshalb insert-only.'; + +-- Die EINZIGE Lesestelle des Payloads: command- und kanalgebundene +-- definer-Funktion. +create function app.read_idempotency_result(p_operation text, p_key text) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_org uuid; + v_payload jsonb; + v_orgs uuid[]; +begin + -- 1. Nur die beiden Payload-Vorgaenge. Ein anderer Operationsname kommt hier + -- nie an; kaeme er doch, ist das ein Aufruferfehler und kein Leseweg. + if p_operation not in ('planning.plan_worksite_day', 'planning.update_worksite_day_team') then + raise exception 'Kein Payload-Vorgang' using errcode = '42501'; + end if; + + -- 2. Laufzeitkanal. + if not app.is_runtime_channel() then + raise exception 'Nur der Laufzeitkanal darf ein Replay-Ergebnis lesen' + using errcode = '42501'; + end if; + + -- 3. ORGANISATION ZUERST, deterministisch und aus dem authentisierten Kontext. + -- Unter `security definer` gibt es KEINE RLS-Filterung — die Mandantengrenze + -- muss die Funktion selbst ziehen, und zwar BEVOR sie eine Zeile auswaehlt. + -- Der Unique-Schluessel von 0012 ist (org_id, operation, idempotency_key), + -- NICHT (operation, key): zwei Organisationen duerfen denselben + -- Vorgangsnamen und denselben Schluessel fuehren. Gemessen am lokalen Stack + -- (zwei Organisationen, gleicher Vorgang, gleicher Schluessel): ein + -- ungebundener Select liefert found=t mit der FREMDEN Zeile, und ein + -- Nachfilter verwarf sie dann — der Aufrufer bekaeme einen Replay-MISS statt + -- seiner Erstantwort. + -- + -- Die Regel ist dieselbe wie im Planning-Command: GENAU EINE aktive + -- Organisation, sonst fail-closed. KEIN order-by-limit-1 — eine Auswahl + -- unter mehreren Mitgliedschaften waere geraten. `v_orgs[1]` ist hier keine + -- Auswahl, sondern die Identitaet: der Zugriff erfolgt erst, nachdem genau + -- ein Element festgestellt wurde. (`min(o)` waere die naheliegende Kurzform + -- und ist NICHT verwendbar: PostgreSQL kennt kein min(uuid).) + -- + -- `into strict` waere die kuerzere Schreibweise, ist hier aber bewusst NICHT + -- gewaehlt: sie wirft NO_DATA_FOUND = P0002, und diesen Code fuehrt + -- `app.remove_assignment_from_worksite_day` in dieser Migration bereits mit + -- einer anderen Bedeutung. Zwei Bedeutungen fuer einen SQLSTATE waeren an + -- einer Sicherheitsgrenze eine vermeidbare Mehrdeutigkeit. + -- + -- ROLLENVERTEILUNG: die Anwendung hat die Eindeutigkeit bereits vor der + -- Idempotenz geprueft und dort NO_ORGANISATION/AMBIGUOUS_ORGANISATION + -- beantwortet. Dieser Riegel ist Tiefenverteidigung: loest er aus, ist eine + -- Invariante verletzt, kein regulaerer Fachfall — deshalb genuegt hier ein + -- einheitliches 42501. + select array_agg(o) into v_orgs from app.user_org_ids() as o; + if v_orgs is null then + raise exception 'Keine aktive Organisation' using errcode = '42501'; + end if; + if array_length(v_orgs, 1) > 1 then + raise exception 'Mehrdeutige Organisation — genau eine aktive Mitgliedschaft erforderlich' + using errcode = '42501'; + end if; + v_org := v_orgs[1]; + + -- 4. Recht auf der aufgeloesten Organisation. VOR der Payload-Abfrage, damit + -- ein fehlendes Recht nichts ueber die Existenz eines Schluessels verraet. + if not app.has_permission(v_org, 'planning.write') then + raise exception 'Kein Planungsrecht' using errcode = '42501'; + end if; + + -- 5. Erst jetzt die Zeile — mandantengebunden ueber den VOLLSTAENDIGEN + -- Unique-Schluessel. + select result_payload + into v_payload + from public.idempotency_records + where org_id = v_org + and operation = p_operation + and idempotency_key = p_key; + + -- 6. Fremder Schluessel und nicht vorhandener Schluessel sind nach aussen + -- identisch: beides null, also "kein Replay" — kein Fehler, der eine + -- fremde Existenz verraet. + if not found then + return null; + end if; + return v_payload; +end +$$; + +comment on function app.read_idempotency_result(text, text) is + 'Die EINZIGE Lesestelle von idempotency_records.result_payload (EYT-147, F1). Nur die beiden Payload-Vorgaenge, nur im Laufzeitkanal, nur mit planning.write, und gebunden an die GENAU EINE aktive Organisation der aufrufenden Identitaet — aufgeloest VOR der Zeilenauswahl, ohne Organisationsparameter und ohne order-by-limit-1. Fremder und unbekannter Schluessel liefern beide null.'; + +revoke all on function app.read_idempotency_result(text, text) from public; +grant execute on function app.read_idempotency_result(text, text) to authenticated; + +-- --------------------------------------------------------------------------- +-- 10. Klasse-(2)-Erwerb als Domain-Funktion (F2/F3) +-- --------------------------------------------------------------------------- +-- WARUM NICHT im Anwendungscode mit einem einfachen `select … for share`: +-- gemessen am lokalen Stack (PostgreSQL 17.6, Laufzeitkanal, Recht +-- `planning.publish` entzogen): +-- A) INVOKER `… where week_key=… and published_at is null for share` -> KEINE ZEILE +-- B) DEFINER dieselbe Abfrage -> Zeile gefunden +-- Ursache ist dieselbe, die 0015 fuer den Assignment-Guard dokumentiert: ein +-- `for share` prueft zusaetzlich die USING-Klausel der UPDATE-Policy von +-- `plan_versions`, und die verlangt `planning.publish`. Eine Sitzung mit nur +-- `planning.write` — der Normalfall dieser Commands — faende die Draft-Zeile +-- nicht und koennte die Postcondition nicht erfuellen. +-- +-- WARUM DIE SCHLEIFE: veroeffentlicht ein Publisher waehrend des Wartens, wertet +-- PostgreSQL die Qualifikation nach dem Commit erneut aus (EvalPlanQual); +-- `published_at is null` trifft nicht mehr zu und das Statement liefert 0 Zeilen. +-- Ohne Wiederholung stuende der Aufrufer ohne Draft da. +-- +-- WARUM DIE MANDANTENAUFLOESUNG VORNE STEHT (F3): unter `security definer` laeuft +-- die Funktion als Eigentuemer, und der traegt BYPASSRLS. Die Mengenform +-- `where org_id in (select app.user_org_ids())` waere hier KEINE Grenze, sondern +-- eine Auswahl. Gemessen an genau dieser Mengenform (isolierte Probe, +-- zurueckgerollt): zwei Orgs ohne Entwurf -> `query returned more than one row`; +-- Org A hat einen Entwurf, Org B nicht -> legt STILL einen Entwurf in B an und +-- gibt dessen Id zurueck. Der mittlere Fall ist der gefaehrliche: ein nach aussen +-- ERFOLGREICHER Command im falschen Mandanten. +create function app.lock_week_draft(p_week_key text) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_id uuid; + v_org uuid; + v_orgs uuid[]; + v_runde integer := 0; +begin + if not app.is_runtime_channel() then + raise exception 'Nur der Laufzeitkanal darf einen Wochenentwurf sperren' + using errcode = '42501'; + end if; + + -- F3: Mandantengrenze zuerst, danach ist jeder Zugriff an `v_org` gebunden. + select array_agg(o) into v_orgs from app.user_org_ids() as o; + if v_orgs is null then + raise exception 'Keine aktive Organisation' using errcode = '42501'; + end if; + if array_length(v_orgs, 1) <> 1 then + raise exception 'Mehrdeutige Organisation - genau eine aktive Mitgliedschaft erforderlich' + using errcode = '42501'; + end if; + v_org := v_orgs[1]; + + if not app.has_permission(v_org, 'planning.write') then + raise exception 'Kein Planungsrecht' using errcode = '42501'; + end if; + + loop + v_runde := v_runde + 1; + + -- (a) Anlegen. Gewinnt diese Transaktion, haelt sie die Zeile ueber den + -- eigenen, noch nicht committeten Insert; das reentrante `for share` in + -- (b) waere dann ein No-op. Die Existenz von `v_org` in + -- `public.organizations` prueft weiterhin der Fremdschluessel von + -- `plan_versions` — ein eigener Lesezugriff darauf entfaellt mit F3 + -- ersatzlos. + insert into public.plan_versions (org_id, week_key) + values (v_org, p_week_key) + on conflict do nothing + returning id into v_id; + if v_id is not null then + return v_id; + end if; + + -- (b) Bestehenden Entwurf sperren. `for share` konfligiert mit dem + -- `for update` des Publishers, nicht aber mit einem zweiten `for share` + -- — zwei Planerinnen derselben Woche behindern einander nicht. + -- `planning.write` ist oben auf `v_org` bereits geprueft; eine zweite + -- Pruefung im Praedikat waere dieselbe Aussage. + select pv.id + into v_id + from public.plan_versions pv + where pv.org_id = v_org + and pv.week_key = p_week_key + and pv.published_at is null + for share; + if v_id is not null then + return v_id; + end if; + + -- (c) Weder angelegt noch gefunden: zwischen (a) und (b) wurde + -- veroeffentlicht. Die naechste Runde legt den Folgedraft an. Begrenzt, + -- damit ein unerwarteter Zustand BENANNT scheitert statt zu kreisen. + if v_runde >= 3 then + raise exception 'Wochenentwurf nicht ermittelbar (% Runden)', v_runde + using errcode = '55P03'; + end if; + end loop; +end +$$; + +comment on function app.lock_week_draft(text) is + 'Erwirbt den Wochenentwurf einer Organisation und haelt ihn bis Transaktionsende als Klasse-(2)-Lock (EYT-147, F2/F3). security definer, weil ein Invoker-for-share die UPDATE-Policy von plan_versions mitpruefte und eine Sitzung mit nur planning.write den Entwurf nicht faende. Ohne Organisationsparameter und ohne order-by-limit-1: die genau eine aktive Organisation wird VOR jedem Zugriff aufgeloest, sonst 42501. Die Wiederholungsrunde faengt einen konkurrierenden Publish ab und legt den Folgedraft an.'; + +revoke all on function app.lock_week_draft(text) from public; +grant execute on function app.lock_week_draft(text) to authenticated; diff --git a/supabase/tests/0005_schema_meta_gate.sql b/supabase/tests/0005_schema_meta_gate.sql index 2a566a2a..dadc0ee1 100644 --- a/supabase/tests/0005_schema_meta_gate.sql +++ b/supabase/tests/0005_schema_meta_gate.sql @@ -66,7 +66,13 @@ insert into expected_tables (table_name, tenant_owned, note) values -- EYT-109). Die Rechte-, Policy- und Kanalzusicherungen dazu stehen in -- supabase/tests/0013_cost_snapshots.sql. ('cost_snapshots', true, 'Unveraenderlicher Kopf eines Plan-Personalkosten-Snapshots (0018)'), - ('cost_snapshot_positions', true, 'Positionen eines Kosten-Snapshots, Reihenfolge per ordinal eingefroren (0018)'); + ('cost_snapshot_positions', true, 'Positionen eines Kosten-Snapshots, Reihenfolge per ordinal eingefroren (0018)'), + -- Stabile Identitaet eines Baustellentages, versionsuebergreifend. Traegt + -- bewusst keine Zeiten und keinen Publish-Marker (0019, EYT-147); die + -- Struktur-, Rechte- und Invariantenzusicherungen stehen in + -- supabase/tests/0014_worksite_days.sql. + ('worksite_days', true, 'Stabile Identitaet eines Baustellentages je Organisation, Baustelle und lokalem Datum (0019)'), + ('worksite_day_configurations', true, 'Revisionsgebundener Planungsstand eines Baustellentages in genau einer Planversion (0019)'); -- --------------------------------------------------------------------------- -- 1. Vollstaendigkeit in beide Richtungen @@ -203,14 +209,39 @@ select is( -- Gegenprobe zu 4 und 5: ohne diese Zeile waeren die Verbote auch dann gruen, -- wenn ueberhaupt keine Rechte vergeben sind — und die Anwendung stuende vor -- lauter permission denied. +-- +-- EINE benannte Ausnahme seit 0019 (EYT-147), sonst unveraendert. +-- +-- 0019 entzieht auf `idempotency_records` das TABELLEN-select, um +-- `result_payload` unlesbar zu machen (R5-1), und vergibt die uebrigen sieben +-- Spalten einzeln neu. GEMESSEN (PostgreSQL 17.6, lokaler Stack nach db reset): +-- has_table_privilege('authenticated','public.idempotency_records','select') = false +-- has_any_column_privilege('authenticated','public.idempotency_records','SELECT') = true +-- Die urspruengliche Fassung ging deshalb rot, obwohl die AUSSAGE dieser Zeile — +-- "die Anwendung laeuft nicht in permission denied" — weiterhin wahr ist: der +-- einzige Bestandsleser liest `subject_id, request_fingerprint`, und beide +-- stehen im Grant. +-- +-- Die Ausnahme ist BEWUSST auf diese eine Tabelle eingegrenzt und nicht +-- repo-weit gezogen. `has_any_column_privilege` ist wahr, sobald IRGENDEINE +-- Spalte das Recht traegt, und schliesst das Tabellenrecht mit ein — als +-- Ersatz fuer ALLE 18 Tabellen waere die Zusicherung damit fuer 17 von ihnen +-- schwaecher als vorher, ohne dass irgendetwas das verlangt. Welche Spalten es +-- bei `idempotency_records` genau sind, misst +-- supabase/tests/0014_worksite_days.sql (B9/B10/E1/E2) exakt; diese Zeile +-- beantwortet hier nur noch "die Anwendung kommt an ihre Daten". select is( ( select coalesce(array_agg(e.table_name order by e.table_name), array[]::text[]) from expected_tables e - where not has_table_privilege('authenticated', 'public.' || e.table_name, 'select') + where case + when e.table_name = 'idempotency_records' + then not has_any_column_privilege('authenticated', 'public.' || e.table_name, 'SELECT') + else not has_table_privilege('authenticated', 'public.' || e.table_name, 'select') + end ), array[]::text[], - 'authenticated hat auf jeder erwarteten Tabelle select — sonst laeuft die Anwendung in permission denied' + 'authenticated hat auf jeder erwarteten Tabelle select — bei idempotency_records seit 0019 spaltenweise, sonst auf Tabellenebene; sonst laeuft die Anwendung in permission denied' ); -- --------------------------------------------------------------------------- diff --git a/supabase/tests/0014_worksite_days.sql b/supabase/tests/0014_worksite_days.sql new file mode 100644 index 00000000..956c7af8 --- /dev/null +++ b/supabase/tests/0014_worksite_days.sql @@ -0,0 +1,1128 @@ +-- 0014_worksite_days — Datenfundament der baustellenzentrierten Planung (EYT-147, M1) +-- +-- ## Was hier gemessen wird +-- +-- Migration 0019 legt die stabile Tagesidentitaet `public.worksite_days` und den +-- revisionsgebundenen Tagesstand `public.worksite_day_configurations` an, ordnet +-- `assignments` additiv unter eine Tageskonfiguration, ergaenzt +-- `idempotency_records.result_payload` samt Lesegrenze und stellt drei neue +-- `app`-Funktionen bereit. +-- +-- ## Warum die Struktur VOR dem Verhalten steht +-- +-- Die Abschnitte A bis D fragen ausschliesslich den Systemkatalog und werfen +-- deshalb auch dann keinen Fehler, wenn die Objekte fehlen — sie melden `not ok`. +-- Genau das ist der rote Ausgangszustand dieses Meilensteins: einzeln benannte +-- Fehlschlaege statt eines einzigen Abbruchs, aus dem sich nichts lesen liesse. +-- Erst Abschnitt E benutzt `has_column_privilege` (das wirft bei fehlender +-- Spalte und beendet den Lauf), Abschnitt F legt die Fixtures an. +-- +-- ## Welche Zusicherungen schon VOR 0019 wahr waren +-- +-- GEMESSEN, indem die Migration beiseitegelegt und `db reset` gefahren wurde: +-- ein durchgehender Lauf meldet 44 rote und FUENF gruene Zusicherungen — B5, B7, +-- B8, C8, C9 —, danach bricht er bei E1 ab (`column "result_payload" … does not +-- exist`). Drei weitere sind hinter der Abbruchstelle ebenfalls vorher gruen und +-- deshalb nur EINZELN messbar: E2, J0 und K2. +-- +-- Diese acht sagen nichts ueber 0019; sie sind PRESERVATION-Waechter und sollen +-- vor UND nach der Migration halten. Jede andere Zusicherung dieser Datei ist im +-- Vor-Zustand rot. Wer eine neunte vorher-gruene findet, hat eine vakuoese +-- Zusicherung gefunden. +-- +-- ## Warum der Kanal hier nur NEGATIV pruefbar ist +-- +-- `app.is_runtime_channel()` vergleicht `session_user` mit `easytree_app`. In +-- pgTAP ist `session_user` immer `postgres`; `SET SESSION AUTHORIZATION` verlangt +-- Superuser und steht hier nicht zur Verfuegung (dieselbe Lage wie in +-- `0012_planning_data_api_boundary.sql`). Diese Datei belegt deshalb, dass ein +-- NICHT-Laufzeitkanal abgewiesen wird. Sie belegt NICHT, dass der echte Kanal +-- durchkommt, und sie erreicht die Zweige `P0002` und `23514` von +-- `app.remove_assignment_from_worksite_day` nicht — der Kanalriegel steht davor. +-- Diese Nachweise liegen in M3/M4 ueber eine echte `easytree_app`-Verbindung. +-- +-- ## Warum die Rechte ueber `aclexplode` und nicht ueber `has_*_privilege` +-- +-- `has_column_privilege` ist wahr, sobald IRGENDEIN Weg das Recht traegt, und +-- beantwortet je Spalte nur eine Ja/Nein-Frage. Die Zusicherung dieses Slices ist +-- aber eine MENGENgleichheit: genau diese Spalten und keine weitere. `aclexplode` +-- ueber `pg_attribute.attacl` liefert die Menge selbst — eine spaeter zusaetzlich +-- gegrantete Spalte faellt damit auf, waehrend eine Reihe von Einzelfragen sie +-- nicht sehen wuerde. Abschnitt E fuehrt die vom Plan woertlich verlangten +-- `has_column_privilege`-Zusicherungen zusaetzlich, nicht ersatzweise. + +begin; +select plan(80); + +-- =========================================================================== +-- A. Struktur beider neuer Tabellen (katalogbasiert, wirft nie) +-- =========================================================================== + +-- A1/A2: die Spaltenmenge EXAKT — das ist zugleich der Nachweis der beiden +-- Negativvorgaben aus dem Plan: keine Zeitspalten, kein eigener Publish-Marker, +-- keine vorgezogenen EYT-120-Felder. Eine Einzelfrage je erwarteter Spalte +-- koennte das nicht sagen. +select is( + ( + select coalesce(array_agg( + a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod) + || case when a.attnotnull then ' NOT NULL' else ' NULL' end + order by a.attnum), array[]::text[]) + from pg_attribute a + where a.attrelid = to_regclass('public.worksite_days') + and a.attnum > 0 and not a.attisdropped + ), + array[ + 'id uuid NOT NULL', + 'org_id uuid NOT NULL', + 'worksite_id uuid NOT NULL', + 'local_date date NOT NULL', + 'created_at timestamp with time zone NOT NULL' + ], + 'A1 worksite_days traegt exakt die fuenf Identitaetsspalten — keine Zeiten, kein Publish-Marker, kein EYT-120-Feld' +); + +select is( + ( + select coalesce(array_agg( + a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod) + || case when a.attnotnull then ' NOT NULL' else ' NULL' end + order by a.attnum), array[]::text[]) + from pg_attribute a + where a.attrelid = to_regclass('public.worksite_day_configurations') + and a.attnum > 0 and not a.attisdropped + ), + array[ + 'id uuid NOT NULL', + 'org_id uuid NOT NULL', + 'worksite_day_id uuid NOT NULL', + 'plan_version_id uuid NOT NULL', + 'lock_version integer NOT NULL', + 'created_at timestamp with time zone NOT NULL' + ], + 'A2 worksite_day_configurations traegt exakt die sechs Revisionsspalten — keine Taetigkeiten, Ressourcen, Status-, Default- oder Zeitraumfelder' +); + +-- A3/A4: Constraints EXAKT, inklusive der beiden Identitaetsgarantien I-3 und +-- I-4 und der tenantgebundenen Fremdschluessel. Gemessen (PostgreSQL 17.6, +-- lokaler Stack, Probetabellen, Transaktion zurueckgerollt): der laengste +-- generierte Name ist `worksite_day_configurations_plan_version_id_worksite_day_id_key` +-- mit 63 Zeichen und wird damit NICHT abgeschnitten (NAMEDATALEN-1 = 63). +select is( + ( + select coalesce(array_agg(c.conname::text || ' :: ' || pg_get_constraintdef(c.oid) + order by c.conname::text), array[]::text[]) + from pg_constraint c + where c.conrelid = to_regclass('public.worksite_days') + ), + array[ + 'worksite_days_id_org_id_key :: UNIQUE (id, org_id)', + 'worksite_days_org_id_fkey :: FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE', + 'worksite_days_org_id_worksite_id_local_date_key :: UNIQUE (org_id, worksite_id, local_date)', + 'worksite_days_pkey :: PRIMARY KEY (id)', + 'worksite_days_worksite_id_org_id_fkey :: FOREIGN KEY (worksite_id, org_id) REFERENCES worksites(id, org_id) ON DELETE RESTRICT' + ], + 'A3 worksite_days: I-4 (org_id, worksite_id, local_date) unique, (id, org_id) unique als FK-Ziel, tenantgebundene Worksite-FK mit RESTRICT' +); + +select is( + ( + select coalesce(array_agg(c.conname::text || ' :: ' || pg_get_constraintdef(c.oid) + order by c.conname::text), array[]::text[]) + from pg_constraint c + where c.conrelid = to_regclass('public.worksite_day_configurations') + ), + array[ + 'worksite_day_configurations_id_org_id_key :: UNIQUE (id, org_id)', + 'worksite_day_configurations_lock_version_check :: CHECK ((lock_version >= 0))', + 'worksite_day_configurations_org_id_fkey :: FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE', + 'worksite_day_configurations_pkey :: PRIMARY KEY (id)', + 'worksite_day_configurations_plan_version_id_org_id_fkey :: FOREIGN KEY (plan_version_id, org_id) REFERENCES plan_versions(id, org_id) ON DELETE CASCADE', + 'worksite_day_configurations_plan_version_id_worksite_day_id_key :: UNIQUE (plan_version_id, worksite_day_id)', + 'worksite_day_configurations_worksite_day_id_org_id_fkey :: FOREIGN KEY (worksite_day_id, org_id) REFERENCES worksite_days(id, org_id)' + ], + 'A4 worksite_day_configurations: I-3 (plan_version_id, worksite_day_id) unique, tenantgebundene FKs, Kaskade NUR an plan_versions' +); + +select is( + ( + select coalesce(array_agg(c.relname::text order by c.relname::text), array[]::text[]) + from pg_index i join pg_class c on c.oid = i.indexrelid + where i.indrelid = to_regclass('public.worksite_day_configurations') + and not i.indisunique and not i.indisprimary + ), + array['worksite_day_configurations_day_idx', 'worksite_day_configurations_version_idx'], + 'A5 worksite_day_configurations traegt die beiden Leseindizes auf Planversion und Tag' +); + +-- A5b: der Leseindex auf der Assignment-Seite. A5 zaehlt nur die beiden Indizes +-- AUF worksite_day_configurations und saehe sein Verschwinden nicht. +select is( + ( + select count(*)::int + from pg_index i join pg_class c on c.oid = i.indexrelid + where i.indrelid = to_regclass('public.assignments') + and c.relname = 'assignments_worksite_day_configuration_idx' + ), + 1, + 'A5b assignments traegt den Leseindex auf worksite_day_configuration_id' +); + +-- A6: die additive Assignment-Spalte. NULLABLE ist die Aussage — Bestandszeilen +-- werden nicht angefasst, und ein Backfill findet nicht statt. +select is( + ( + select a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod) + || case when a.attnotnull then ' NOT NULL' else ' NULL' end + from pg_attribute a + where a.attrelid = to_regclass('public.assignments') + and a.attname = 'worksite_day_configuration_id' and not a.attisdropped + ), + 'worksite_day_configuration_id uuid NULL', + 'A6 assignments.worksite_day_configuration_id ist additiv und NULLABLE — Bestandszeilen bleiben gueltig' +); + +select is( + ( + select pg_get_constraintdef(c.oid) + from pg_constraint c + where c.conrelid = to_regclass('public.assignments') + and c.conname = 'assignments_worksite_day_configuration_fk' + ), + 'FOREIGN KEY (worksite_day_configuration_id, org_id) REFERENCES worksite_day_configurations(id, org_id)', + 'A7 assignments: tenantgebundene FK auf die Tageskonfiguration, ohne Kaskade' +); + +select is( + ( + select a.attname::text || ' ' || format_type(a.atttypid, a.atttypmod) + || case when a.attnotnull then ' NOT NULL' else ' NULL' end + from pg_attribute a + where a.attrelid = to_regclass('public.idempotency_records') + and a.attname = 'result_payload' and not a.attisdropped + ), + 'result_payload jsonb NULL', + 'A8 idempotency_records.result_payload ist additiv und NULLABLE — Bestandsvorgaenge bleiben schreibbar' +); + +-- A9: die beiden Pflicht-Checks auf result_payload. Der zweite ist der +-- eigentliche Riegel: ein payloadloser Insert der neuen Vorgaenge ist 23514, +-- nicht ein stiller Zustand, in dem ein Replay weder reproduzieren noch ehrlich +-- scheitern koennte. +select is( + ( + select coalesce(array_agg(c.conname::text order by c.conname::text), array[]::text[]) + from pg_constraint c + where c.conrelid = to_regclass('public.idempotency_records') + and c.contype = 'c' + and c.conname like 'idempotency_records_%payload%' + ), + array['idempotency_records_result_payload_is_object', + 'idempotency_records_worksite_day_ops_need_payload'], + 'A9 idempotency_records: Objektform UND Payloadpflicht der beiden WorksiteDay-Vorgaenge sind Datenbankregeln' +); + +-- =========================================================================== +-- B. Rechtelage als MENGE (katalogbasiert ueber aclexplode, wirft nie) +-- =========================================================================== + +select is( + ( + select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.worksite_days') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT' + ), + array['local_date', 'org_id', 'worksite_id'], + 'B1 worksite_days: INSERT erreicht exakt (org_id, worksite_id, local_date) — id und created_at bleiben der Datenbank' +); + +select is( + ( + select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.worksite_day_configurations') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT' + ), + array['lock_version', 'org_id', 'plan_version_id', 'worksite_day_id'], + 'B2 worksite_day_configurations: INSERT erreicht exakt die vier fachlichen Spalten' +); + +select is( + ( + select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.worksite_day_configurations') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'UPDATE' + ), + array['lock_version'], + 'B3 worksite_day_configurations: UPDATE erreicht ausschliesslich lock_version' +); + +-- B4: kein DELETE auf beiden neuen Tabellen. `delete` kennt kein Spaltenrecht, +-- die Frage geht deshalb an relacl. +select is( + ( + select coalesce(array_agg(c.relname::text || ':' || ac.privilege_type order by c.relname::text, ac.privilege_type), array[]::text[]) + from pg_class c + cross join lateral aclexplode(c.relacl) ac + where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations')) + and ac.grantee = 'authenticated'::regrole + ), + array['worksite_day_configurations:SELECT', 'worksite_days:SELECT'], + 'B4 beide neuen Tabellen: auf Tabellenebene traegt authenticated NUR select — kein delete, kein pauschales insert/update' +); + +select is( + ( + select coalesce(array_agg(c.relname::text || ':' || ac.privilege_type order by c.relname::text, ac.privilege_type), array[]::text[]) + from pg_class c + cross join lateral aclexplode(c.relacl) ac + where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations')) + and ac.grantee = 'anon'::regrole + ), + array[]::text[], + 'B5 beide neuen Tabellen: anon traegt kein einziges Recht' +); + +-- B6/B7: der 0017-Grantumfang auf assignments waechst um GENAU eine Spalte. +select is( + ( + select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.assignments') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'INSERT' + ), + array['employee_id', 'ends_at_utc', 'org_id', 'plan_version_id', 'starts_at_utc', + 'worksite_day_configuration_id', 'worksite_id'], + 'B6 assignments: der INSERT-Grant waechst um genau worksite_day_configuration_id — id bleibt draussen (0012 A4)' +); + +-- Gefiltert auf die vier DML-Rechte, wie in 0005 Regel 4/5/7. Gemessen +-- (PostgreSQL 17.6, lokaler Stack nach 0019): `authenticated` traegt auf +-- `public.assignments` ausserdem MAINTAIN, REFERENCES, TRIGGER und TRUNCATE — +-- und zwar auf `public.plan_versions` genauso, also aus den Supabase- +-- Default-Privilegien und NICHT aus dieser Migration. Sie hier mitzuzaehlen +-- machte die Zusicherung zu einer Aussage ueber die Plattform statt ueber den +-- 0017-Grantumfang, den sie bewachen soll. +select is( + ( + select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[]) + from pg_class c + cross join lateral aclexplode(c.relacl) ac + where c.oid = to_regclass('public.assignments') and ac.grantee = 'authenticated'::regrole + and ac.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + ), + array['SELECT'], + 'B7 assignments: von den vier DML-Rechten bleibt auf Tabellenebene NUR select — kein update-, kein delete-Grant (0012 A1/A2 bleiben wahr)' +); + +select is( + ( + select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.assignments') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type in ('UPDATE', 'DELETE') + ), + array[]::text[], + 'B8 assignments: auch spaltenweise traegt authenticated kein update- und kein delete-Recht' +); + +-- B9/B10: die Lesegrenze des Payloads sitzt auf der SPALTE. Das Tabellenrecht +-- select ist entzogen, die uebrigen sieben Spalten sind einzeln gegrantet, und +-- result_payload ist in dieser Menge NICHT enthalten. +select is( + ( + select coalesce(array_agg(a.attname::text order by a.attname::text), array[]::text[]) + from pg_attribute a + cross join lateral aclexplode(a.attacl) ac + where a.attrelid = to_regclass('public.idempotency_records') + and ac.grantee = 'authenticated'::regrole and ac.privilege_type = 'SELECT' + ), + array['created_at', 'id', 'idempotency_key', 'operation', 'org_id', + 'request_fingerprint', 'subject_id'], + 'B9 idempotency_records: SELECT ist spaltenweise vergeben und result_payload steht NICHT darin' +); + +select is( + ( + select coalesce(array_agg(distinct ac.privilege_type order by ac.privilege_type), array[]::text[]) + from pg_class c + cross join lateral aclexplode(c.relacl) ac + where c.oid = to_regclass('public.idempotency_records') and ac.grantee = 'authenticated'::regrole + and ac.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + ), + array['INSERT'], + 'B10 idempotency_records: von den vier DML-Rechten bleibt auf Tabellenebene NUR insert — select ist entzogen und spaltenweise neu vergeben, update und delete gab es nie' +); + +-- =========================================================================== +-- C. Policy- und RLS-Lage (katalogbasiert, wirft nie) +-- =========================================================================== + +select is( + ( + select coalesce(array_agg(c.relname::text || ' rls=' || c.relrowsecurity::text + || ' forced=' || c.relforcerowsecurity::text + order by c.relname::text), array[]::text[]) + from pg_class c + where c.oid in (to_regclass('public.worksite_days'), to_regclass('public.worksite_day_configurations')) + ), + array['worksite_day_configurations rls=true forced=true', 'worksite_days rls=true forced=true'], + 'C1 beide neuen Tabellen haben RLS aktiviert UND erzwungen' +); + +select is( + ( + select coalesce(array_agg(p.cmd::text order by p.cmd::text), array[]::text[]) + from pg_policies p + where p.schemaname = 'public' and p.tablename = 'worksite_days' + ), + array['INSERT', 'SELECT'], + 'C2 worksite_days: genau zwei Policies (select, insert) — keine update-, keine delete-Policy, die ein spaeteres grant reaktivieren koennte' +); + +select is( + ( + select coalesce(array_agg(p.cmd::text order by p.cmd::text), array[]::text[]) + from pg_policies p + where p.schemaname = 'public' and p.tablename = 'worksite_day_configurations' + ), + array['INSERT', 'SELECT', 'UPDATE'], + 'C3 worksite_day_configurations: genau drei Policies — keine delete-Policy' +); + +select ok( + ( + select coalesce(qual, '') || coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'worksite_days' and cmd = 'INSERT' + ) like '%is_runtime_channel%', + 'C4 worksite_days: die INSERT-Policy prueft den Laufzeitkanal' +); + +select ok( + ( + select coalesce(qual, '') || coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'worksite_days' and cmd = 'INSERT' + ) like '%planning.write%', + 'C5 worksite_days: die INSERT-Policy prueft das atomare Recht planning.write' +); + +select ok( + ( + select coalesce(qual, '') || coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'INSERT' + ) like '%is_runtime_channel%' + and ( + select coalesce(qual, '') || coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'INSERT' + ) like '%planning.write%', + 'C6 worksite_day_configurations: die INSERT-Policy prueft Kanal UND planning.write' +); + +-- C7 misst die beiden Klauseln EINZELN. Eine frueher Fassung verkettete +-- `qual || with_check` zu einem String und suchte darin — ein Treffer in nur +-- EINER Haelfte genuegte dann, obwohl der Zusicherungstext "in beiden Klauseln" +-- behauptet (Review-MESSUNG: eine UPDATE-Policy mit vollem `using`, aber +-- `with check (org_id in (…))` ohne Kanal und ohne Recht, blieb gruen). +select is( + ( + select coalesce(array_agg(teil || '=' || (klausel like '%is_runtime_channel%' + and klausel like '%planning.write%')::text + order by teil), array[]::text[]) + from ( + select 'qual' as teil, coalesce(qual, '') as klausel from pg_policies + where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'UPDATE' + union all + select 'with_check', coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'worksite_day_configurations' and cmd = 'UPDATE' + ) k + ), + array['qual=true', 'with_check=true'], + 'C7 worksite_day_configurations: die UPDATE-Policy prueft Kanal UND planning.write in JEDER der beiden Klauseln einzeln' +); + +-- C4b/C5b: die MANDANTENGRENZE der beiden Lese-Policies. Sie ist dort das +-- EINZIGE Glied — ohne diese Zusicherung liesse sich `using (org_id in (select +-- app.user_org_ids()))` durch `using (true)` ersetzen, und ALLE 14 pgTAP-Dateien +-- des Repos blieben gruen (Review-MESSUNG, Gegenmutation in einer Transaktion). +select is( + ( + select coalesce(array_agg(p.tablename::text || ' :: ' || coalesce(p.qual, '') + order by p.tablename::text), array[]::text[]) + from pg_policies p + where p.schemaname = 'public' and p.cmd = 'SELECT' + and p.tablename in ('worksite_days', 'worksite_day_configurations') + ), + array[ + 'worksite_day_configurations :: (org_id IN ( SELECT app.user_org_ids() AS user_org_ids))', + 'worksite_days :: (org_id IN ( SELECT app.user_org_ids() AS user_org_ids))' + ], + 'C4b beide Lese-Policies binden woertlich an die aktiven Mitgliedschaften der Identitaet — hier ist die Mandantengrenze das einzige Glied' +); + +-- C5b: und die INSERT-Policies tragen dasselbe Glied. Heute deckt +-- `app.has_permission(org_id, …)` denselben Mandanten mit ab; die Zusicherung +-- schuetzt die Tiefenverteidigung, nicht die heutige Grenze. +select is( + ( + select count(*)::int from pg_policies + where schemaname = 'public' and cmd = 'INSERT' + and tablename in ('worksite_days', 'worksite_day_configurations') + and coalesce(with_check, '') like '%org_id IN ( SELECT app.user_org_ids()%' + ), + 2, + 'C5b beide INSERT-Policies binden zusaetzlich zu Kanal und Recht ausdruecklich an die Organisation' +); + +-- C8: 0012 A/B-Erhalt an der Grenze. Genau EINE Schreibpolicy auf assignments — +-- keine zweite, die neben der 0017-Fassung stuende. +select is( + ( + select count(*)::int from pg_policies + where schemaname = 'public' and tablename = 'assignments' + and cmd in ('INSERT', 'UPDATE', 'DELETE') + ), + 1, + 'C8 assignments: es bleibt bei GENAU EINER Schreibpolicy (INSERT) — 0019 fuegt keine zweite hinzu' +); + +-- C9: die SELECT-Policy von idempotency_records ist gegenueber 0012 woertlich +-- unveraendert. Die Grenze soll auf der Spalte sitzen (B9), nicht in der +-- Zeilenbedingung — sonst verschoebe sich unbemerkt, was 0012 zusichert. +select is( + ( + select qual from pg_policies + where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'SELECT' + ), + '(org_id IN ( SELECT app.user_org_ids() AS user_org_ids))', + 'C9 idempotency_records: die SELECT-Policy ist byte-gleich zur 0012-Fassung — die Lesegrenze sitzt auf der Spalte, nicht in der Zeile' +); + +select ok( + ( + select coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'INSERT' + ) like '%is_runtime_channel%', + 'C10 idempotency_records: die INSERT-Policy verlangt den Laufzeitkanal, sobald ein result_payload geschrieben wird' +); + +-- C10b: und zwar OPERATIONSGEBUNDEN ueber die Payloadbedingung, nicht pauschal — +-- sonst braeche `costs.create_rate_version`, dessen Bestandssuite ueber eine +-- postgres-Sitzung laeuft. +select ok( + ( + select coalesce(with_check, '') from pg_policies + where schemaname = 'public' and tablename = 'idempotency_records' and cmd = 'INSERT' + ) like '%result_payload IS NULL%', + 'C11 idempotency_records: die Kanalpflicht haengt an result_payload — ein payloadloser Bestandsvorgang bleibt ohne Kanal schreibbar' +); + +-- =========================================================================== +-- D. Struktur der neuen Funktionen (Stolperdraehte nach dem 0006-Muster) +-- =========================================================================== + +select is( + ( + select coalesce(array_agg(p.proname::text || '=' || p.prosecdef::text order by p.proname::text), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' + and p.proname in ('reject_worksite_day_configuration_change_in_published_plan', + 'remove_assignment_from_worksite_day', + 'read_idempotency_result', + 'lock_week_draft', + 'assignment_belongs_to_worksite_day_configuration') + ), + array[ + 'assignment_belongs_to_worksite_day_configuration=false', + 'lock_week_draft=true', + 'read_idempotency_result=true', + 'reject_worksite_day_configuration_change_in_published_plan=true', + 'remove_assignment_from_worksite_day=true' + ], + 'D1 alle fuenf neuen app-Funktionen existieren mit der geplanten Rechtestellung — vier definer, der Zugehoerigkeitstrigger bewusst invoker' +); + +select is( + ( + -- `proconfig::text` rendert die Array-Literalform mit Backslash-Escapes + -- (`{"search_path=\"\""}`) und machte den Vergleich zu einer Aussage ueber + -- die Quotierung. `array_to_string` liefert den Wert selbst. + select coalesce(array_agg(p.proname::text || ' -> ' + || coalesce(array_to_string(p.proconfig, ','), '') + order by p.proname::text), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' + and p.proname in ('reject_worksite_day_configuration_change_in_published_plan', + 'remove_assignment_from_worksite_day', + 'read_idempotency_result', + 'lock_week_draft', + 'assignment_belongs_to_worksite_day_configuration') + ), + array[ + 'assignment_belongs_to_worksite_day_configuration -> search_path=""', + 'lock_week_draft -> search_path=""', + 'read_idempotency_result -> search_path=""', + 'reject_worksite_day_configuration_change_in_published_plan -> search_path=""', + 'remove_assignment_from_worksite_day -> search_path=""' + ], + 'D2 alle fuenf neuen Funktionen setzen search_path = "" — kein Suchpfad aus der Sitzung' +); + +select is( + ( + select coalesce(array_agg(g.g order by g.g), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + cross join lateral unnest(p.proacl::text[]) as g(g) + where n.nspname = 'app' and p.proname = 'read_idempotency_result' + ), + array['authenticated=X/postgres', 'postgres=X/postgres'], + 'D3 app.read_idempotency_result: execute nur fuer authenticated und den Eigentuemer — public ist entzogen' +); + +select is( + ( + select coalesce(array_agg(g.g order by g.g), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + cross join lateral unnest(p.proacl::text[]) as g(g) + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ), + array['authenticated=X/postgres', 'postgres=X/postgres'], + 'D4 app.lock_week_draft: execute nur fuer authenticated und den Eigentuemer' +); + +select is( + ( + select coalesce(array_agg(g.g order by g.g), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + cross join lateral unnest(p.proacl::text[]) as g(g) + where n.nspname = 'app' and p.proname = 'remove_assignment_from_worksite_day' + ), + array['authenticated=X/postgres', 'postgres=X/postgres'], + 'D5 app.remove_assignment_from_worksite_day: execute nur fuer authenticated und den Eigentuemer' +); + +-- D6/D7: F1-Strukturhaelfte. Die Signatur traegt KEINEN Organisationsparameter — +-- den duerfte sonst der Client waehlen —, und der ausfuehrbare Rumpf enthaelt +-- weder `limit 1` noch `order by`, also keine geratene Auswahl unter mehreren +-- Mitgliedschaften. Der Match strippt vorher die Kommentare: `pg_get_functiondef` +-- liefert sie mit, und die normative Begruendung des Designs nennt +-- `order by … limit 1` woertlich als das Ausgeschlossene — die naive Fassung +-- ginge auf einer KORREKTEN Implementierung rot und belohnte das Loeschen genau +-- dieser Begruendung. +select is( + ( + select pg_get_function_arguments(p.oid) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'read_idempotency_result' + ), + 'p_operation text, p_key text', + 'D6 app.read_idempotency_result traegt KEINEN Organisationsparameter — die Mandantenbindung kommt aus dem authentisierten Kontext' +); + +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'read_idempotency_result' + ) !~* '(limit +1|order +by)', + 'D7 app.read_idempotency_result: der ausfuehrbare Rumpf enthaelt weder limit 1 noch order by — keine geratene Organisationsauswahl' +); + +-- D8: und die Auswahl ist an den VOLLSTAENDIGEN Unique-Schluessel gebunden. +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'read_idempotency_result' + ) ~* 'org_id *= *v_org', + 'D8 app.read_idempotency_result: die Payloadabfrage ist an die aufgeloeste Organisation gebunden, nicht nur nachgefiltert' +); + +-- D9 bis D12: F2/F3-Strukturhaelfte von app.lock_week_draft. +select is( + ( + select pg_get_function_arguments(p.oid) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ), + 'p_week_key text', + 'D9 app.lock_week_draft traegt KEINEN Organisationsparameter (F3)' +); + +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ) ~* 'for +share', + 'D10 app.lock_week_draft erwirbt einen Klasse-(2)-Lock mit for share — die Invoker-Variante im Anwendungscode ist damit ausgeschlossen' +); + +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ) ~* 'loop', + 'D11 app.lock_week_draft enthaelt die Wiederholungsrunde — nach einem konkurrierenden Publish steht der Aufrufer nicht ohne Entwurf da' +); + +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ) !~* '(limit +1|order +by|org_id +in *\()', + 'D12 app.lock_week_draft: kein limit 1, kein order by und KEINE Mengenform org_id in ( — unter definer waere die Menge eine Auswahl, keine Grenze (F3)' +); + +select ok( + ( + select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g') + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.proname = 'lock_week_draft' + ) ~* 'org_id *= *v_org', + 'D13 app.lock_week_draft: jeder Zugriff ist explizit an die eine aufgeloeste Organisation gebunden' +); + +-- D14: die Zusicherung, gegenueber der 0012 A1/A2/B1/C2/C3 strukturell BLIND +-- sind. Gemessen: bei gruenem A2/B1/C3 loeschte ein definer-Weg die Zeile +-- trotzdem, weil der Eigentuemer weder Grant noch Policy passiert. Genau EINE +-- benannte app-Funktion darf mit Ownerrechten aus public.assignments loeschen. +select is( + ( + select coalesce(array_agg(p.proname::text order by p.proname::text), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' and p.prokind = 'f' and p.prosecdef + and pg_get_functiondef(p.oid) ~* 'delete +from +public\.assignments' + ), + array['remove_assignment_from_worksite_day'], + 'D14 genau eine security-definer-Funktion in app darf aus public.assignments loeschen — jede weitere waere ein Loch, das 0012 A1/A2/B1/C2/C3 nicht sehen' +); + +-- D15: die REICHWEITE beider Trigger, nicht nur ihre Existenz. Der Aufnahme-Guard +-- muss auch beim UMHAENGEN feuern (`update of plan_version_id`); auf `before +-- insert` reduziert, liesse sich eine Tageskonfiguration nachtraeglich in eine +-- veroeffentlichte Version haengen. Der Unveraenderlichkeits-Guard muss `delete` +-- UND `update` abdecken. +select is( + ( + select coalesce(array_agg(t.tgname::text || ' :: ' || pg_get_triggerdef(t.oid) + order by t.tgname::text), array[]::text[]) + from pg_trigger t + where t.tgrelid = to_regclass('public.worksite_day_configurations') and not t.tgisinternal + ), + array[ + 'worksite_day_configurations_immutable_when_published :: CREATE TRIGGER worksite_day_configurations_immutable_when_published BEFORE DELETE OR UPDATE ON public.worksite_day_configurations FOR EACH ROW EXECUTE FUNCTION app.reject_worksite_day_configuration_change_in_published_plan()', + 'worksite_day_configurations_reject_published_plan :: CREATE TRIGGER worksite_day_configurations_reject_published_plan BEFORE INSERT OR UPDATE OF plan_version_id ON public.worksite_day_configurations FOR EACH ROW EXECUTE FUNCTION app.reject_assignment_in_published_plan()' + ], + 'D15 beide Trigger auf worksite_day_configurations haben exakt die geplante Reichweite — der Aufnahme-Guard feuert auch beim Umhaengen, der Unveraenderlichkeits-Guard bei update UND delete' +); + +-- D16: auch die beiden TRIGGERfunktionen haben PUBLIC entzogen. D3/D4/D5 pruefen +-- nur die drei aufrufbaren Funktionen; ohne diese Zeile bliebe ein +-- `grant execute … to public` auf einer Triggerfunktion unbemerkt. +select is( + ( + select coalesce(array_agg(p.proname::text || ' :: ' + || coalesce(array_to_string(p.proacl::text[], ','), '') + order by p.proname::text), array[]::text[]) + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' + and p.proname in ('reject_worksite_day_configuration_change_in_published_plan', + 'assignment_belongs_to_worksite_day_configuration') + ), + array[ + 'assignment_belongs_to_worksite_day_configuration :: postgres=X/postgres', + 'reject_worksite_day_configuration_change_in_published_plan :: postgres=X/postgres' + ], + 'D16 beide neuen Triggerfunktionen tragen NUR das Eigentuemerrecht — PUBLIC ist entzogen, und sie sind fuer niemanden direkt aufrufbar' +); + +-- =========================================================================== +-- E. Rechte in der vom Plan woertlich verlangten Form +-- =========================================================================== +-- Abschnitt B misst dieselbe Sache als Menge und ist damit schaerfer. Diese vier +-- Zeilen fuehren die Frage zusaetzlich so, wie der akzeptierte Plan sie stellt — +-- und sie WERFEN bei fehlender Spalte, stehen deshalb hinter dem katalogbasierten +-- Teil. + +select ok( + not has_column_privilege('authenticated', 'public.idempotency_records', 'result_payload', 'SELECT'), + 'E1 (P2) result_payload ist fuer authenticated auf KEINEM direkten Weg lesbar — weder ueber die Data-API noch roh im Laufzeitkanal' +); + +select ok( + has_column_privilege('authenticated', 'public.idempotency_records', 'subject_id', 'SELECT') + and has_column_privilege('authenticated', 'public.idempotency_records', 'request_fingerprint', 'SELECT'), + 'E2 (P1) der einzige Bestandsleser (subject_id, request_fingerprint) behaelt sein Recht — alle vier Bestandsvorgaenge bleiben unberuehrt' +); + +select ok( + has_column_privilege('authenticated', 'public.idempotency_records', 'result_payload', 'INSERT'), + 'E3 das Tabellen-INSERT aus 0012 deckt die neue Spalte mit ab — gemessen statt angenommen' +); + +select ok( + has_column_privilege('authenticated', 'public.assignments', 'worksite_day_configuration_id', 'INSERT') + and not has_column_privilege('authenticated', 'public.assignments', 'id', 'INSERT'), + 'E4 assignments: die neue Spalte ist insert-bar, id bleibt es nicht (0012 A4 laeuft nach der Neuvergabe erneut)' +); + +-- =========================================================================== +-- F. Fixtures (Eigentuemerpfad — PO-Vorgabe 07.08.2026) +-- =========================================================================== +-- Zwei Baustellen, drei Planversionen (ein Entwurf zum Arbeiten, eine +-- veroeffentlichte fuer die Unveraenderlichkeit, ein Entwurf zum Loeschen), zwei +-- Tagesidentitaeten und drei Tageskonfigurationen. Alles in Org Alpha; die +-- Transaktion wird am Ende zurueckgerollt. + +insert into public.worksites (id, org_id, name, activity_count) +values ('00000000-0000-4000-8000-0000005011a1', '00000000-0000-4000-8000-0000000000a1', + 'Alpha Allee 2', 1); + +insert into public.plan_versions (id, org_id, week_key) +values ('00000000-0000-4000-8000-0000006040a1', '00000000-0000-4000-8000-0000000000a1', '2026-W40'), + ('00000000-0000-4000-8000-0000006041a1', '00000000-0000-4000-8000-0000000000a1', '2026-W41'), + ('00000000-0000-4000-8000-0000006042a1', '00000000-0000-4000-8000-0000000000a1', '2026-W42'); + +insert into public.worksite_days (id, org_id, worksite_id, local_date) +values ('00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000005010a1', '2026-09-28'), + ('00000000-0000-4000-8000-0000008011a1', '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000005010a1', '2026-09-29'); + +insert into public.worksite_day_configurations + (id, org_id, worksite_day_id, plan_version_id) +values + -- Arbeitsstand im Entwurf W40 + ('00000000-0000-4000-8000-0000009010a1', '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000006040a1'), + -- Stand in W41, der GLEICH VEROEFFENTLICHT wird + ('00000000-0000-4000-8000-0000009011a1', '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000008010a1', '00000000-0000-4000-8000-0000006041a1'), + -- Stand im Entwurf W42, der gleich mit seinem Entwurf geloescht wird + ('00000000-0000-4000-8000-0000009012a1', '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000008011a1', '00000000-0000-4000-8000-0000006042a1'); + +update public.plan_versions + set published_at = '2026-10-05T12:00:00Z', + published_by = '00000000-0000-4000-8000-00000000aaa1' + where id = '00000000-0000-4000-8000-0000006041a1'; + +-- =========================================================================== +-- G. Negative Datenbank-Invarianten +-- =========================================================================== +-- SQLSTATE allein waere mehrdeutig: 23514 entsteht aus jedem CHECK und aus jedem +-- Trigger, der ihn erhebt. Jede Zusicherung nennt deshalb zusaetzlich den +-- Wortlaut — nur so sagt der rote Lauf, WELCHER Riegel gehalten hat. + +select throws_ok( + $$insert into public.worksite_day_configurations (org_id, worksite_day_id, plan_version_id) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000008011a1', + '00000000-0000-4000-8000-0000006041a1')$$, + '23514', + 'Planversion 00000000-0000-4000-8000-0000006041a1 ist veroeffentlicht und nimmt keine Zuweisung mehr auf', + 'G1 eine veroeffentlichte Planversion nimmt KEINE neue Tageskonfiguration mehr auf (Aufnahme-Guard, generische 0016-Fassung wiederverwendet)' +); + +select throws_ok( + $$update public.worksite_day_configurations set lock_version = 1 + where id = '00000000-0000-4000-8000-0000009011a1'$$, + '23514', + 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich', + 'G2 die Tageskonfiguration einer veroeffentlichten Version laesst sich nicht aendern — ohne eigenen Publish-Marker, der Versionszustand wird gelesen' +); + +select throws_ok( + $$delete from public.worksite_day_configurations + where id = '00000000-0000-4000-8000-0000009011a1'$$, + '23514', + 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich', + 'G3 die Tageskonfiguration einer veroeffentlichten Version laesst sich nicht loeschen' +); + +select throws_ok( + $$insert into public.worksite_day_configurations (org_id, worksite_day_id, plan_version_id) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000008010a1', + '00000000-0000-4000-8000-0000006040a1')$$, + '23505', + NULL, + 'G4 (I-3) ein Baustellentag hat je Planversion HOECHSTENS EINEN Stand — der Riegel ist der in A4 benannte unique-Constraint' +); + +select throws_ok( + $$insert into public.worksite_days (org_id, worksite_id, local_date) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000005010a1', '2026-09-28')$$, + '23505', + NULL, + 'G5 (I-4) derselbe reale Baustellentag ist immer dieselbe Zeile — der Riegel ist der in A3 benannte unique-Constraint' +); + +select throws_ok( + $$insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, + starts_at_utc, ends_at_utc) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000006040a1', + '00000000-0000-4000-8000-0000009010a1', + '00000000-0000-4000-8000-0000004010a1', + '00000000-0000-4000-8000-0000005011a1', + '2026-09-28T06:00:00Z', '2026-09-28T14:00:00Z')$$, + '23514', + 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration', + 'G6 (I-1) eine Zuweisung mit FREMDER Baustelle wird abgewiesen — beide Zeilen sind fuer sich gueltig, nur ihre Kombination nicht' +); + +select throws_ok( + $$insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, + starts_at_utc, ends_at_utc) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000006042a1', + '00000000-0000-4000-8000-0000009010a1', + '00000000-0000-4000-8000-0000004010a1', + '00000000-0000-4000-8000-0000005010a1', + '2026-09-28T06:00:00Z', '2026-09-28T14:00:00Z')$$, + '23514', + 'Zuweisung widerspricht Baustelle oder Planversion ihrer Tageskonfiguration', + 'G7 (I-1) eine Zuweisung mit FREMDER Planversion wird abgewiesen — der Fremdschluessel allein saehe hier nichts' +); + +-- G8: die Zugehoerigkeit einer Tageskonfiguration ist UNVERAENDERLICH. Ohne +-- diesen Riegel liesse sich I-1 NACHTRAEGLICH brechen: der Zugehoerigkeitstrigger +-- auf `assignments` (G6/G7) feuert nur dort und laeuft bei einer Aenderung HIER +-- nie erneut. Review-MESSUNG: beide Netze liessen das Umhaengen durch, danach +-- zeigte die Konfiguration auf Planversion B und ihre Zuweisungen auf A. +select throws_ok( + $$update public.worksite_day_configurations + set plan_version_id = '00000000-0000-4000-8000-0000006042a1' + where id = '00000000-0000-4000-8000-0000009010a1'$$, + '23514', + 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden', + 'G8 eine Tageskonfiguration laesst sich nicht in eine andere Planversion umhaengen — auch nicht in einen ENTWURF, wo der Publish-Guard nichts saehe' +); + +select throws_ok( + $$update public.worksite_day_configurations + set worksite_day_id = '00000000-0000-4000-8000-0000008011a1' + where id = '00000000-0000-4000-8000-0000009010a1'$$, + '23514', + 'Tageskonfiguration kann nicht auf einen anderen Tag oder eine andere Planversion umgehaengt werden', + 'G9 eine Tageskonfiguration laesst sich nicht auf einen anderen Baustellentag umhaengen — der Aufnahme-Guard feuert bei dieser Spalte gar nicht' +); + +-- G10: der Unveraenderlichkeits-Guard loest den Publish-Stand an der ELTERNZEILE +-- auf. Traegt er dabei einen Sichtbarkeitsfilter, erzeugt eine Sitzung mit +-- ORG-FREMDER Identitaet denselben `not found` wie die Loeschkaskade — und der +-- fail-open-Zweig fuer DELETE liesse sie durch. Review-MESSUNG an genau dieser +-- Fassung: `DELETE 1`, die Tageskonfiguration einer VEROEFFENTLICHTEN +-- Planversion war weg, waehrend dieselbe Anweisung ohne Claims korrekt mit 23514 +-- scheiterte. Der Bestandsguard `app.reject_published_row_change` (0010) hat die +-- Luecke nicht, weil er `old.published_at` an der Zeile SELBST liest. +select set_config( + 'request.jwt.claims', + json_build_object('sub', '00000000-0000-4000-8000-00000000bbb2', 'role', 'authenticated')::text, + true +); + +select throws_ok( + $$delete from public.worksite_day_configurations + where id = '00000000-0000-4000-8000-0000009011a1'$$, + '23514', + 'Tageskonfiguration einer veroeffentlichten Planversion ist unveraenderlich', + 'G10 auch eine Sitzung mit ORG-FREMDER Identitaet kann die Tageskonfiguration einer veroeffentlichten Version nicht loeschen — der Guard sieht den Publish-Stand unabhaengig von der Sitzung' +); + +-- Claims wieder entfernen: die folgenden Zusicherungen laufen ohne +-- Anwendungsidentitaet, wie die vorherigen auch. +select set_config('request.jwt.claims', '', true); + +-- =========================================================================== +-- H. Positive Invarianten +-- =========================================================================== + +select lives_ok( + $$insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, + starts_at_utc, ends_at_utc) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000006040a1', + '00000000-0000-4000-8000-0000009010a1', + '00000000-0000-4000-8000-0000004010a1', + '00000000-0000-4000-8000-0000005010a1', + '2026-09-28T06:00:00Z', '2026-09-28T10:00:00Z')$$, + 'H1 eine Zuweisung, deren Baustelle und Planversion zu ihrer Tageskonfiguration passen, wird angenommen' +); + +select lives_ok( + $$insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, + starts_at_utc, ends_at_utc) + values ('00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000006040a1', + '00000000-0000-4000-8000-0000009010a1', + '00000000-0000-4000-8000-0000004010a1', + '00000000-0000-4000-8000-0000005010a1', + '2026-09-28T12:00:00Z', '2026-09-28T16:00:00Z')$$, + 'H2 (F) DIESELBE Person darf an DEMSELBEN Baustellentag ZWEI verschiedene Intervalle haben — der Tag traegt keine Zeit und erzwingt keine' +); + +select is( + ( + select count(*)::int from public.assignments + where worksite_day_configuration_id = '00000000-0000-4000-8000-0000009010a1' + ), + 2, + 'H3 beide Intervalle stehen wirklich an derselben Tageskonfiguration — lives_ok allein sagt nicht, WORAN sie haengen' +); + +-- H4: die Loeschkaskaden-Ausnahme. Gemessen im Review: eine fail-closed-Variante +-- des Unveraenderlichkeits-Guards machte genau das unmoeglich und haette die +-- Aufraeumpfade der Bestandssuiten gebrochen. Im BEFORE-DELETE des Kindes ist die +-- Elternzeile bereits entfernt — "nicht gefunden" ist dort der NORMALFALL. +select lives_ok( + $$delete from public.plan_versions + where id = '00000000-0000-4000-8000-0000006042a1'$$, + 'H4 ein UNVEROEFFENTLICHTER Entwurf mit Tageskonfiguration laesst sich loeschen — die Kaskade laeuft, der Guard blockiert sie nicht' +); + +select is( + ( + select count(*)::int from public.worksite_day_configurations + where id = '00000000-0000-4000-8000-0000009012a1' + ), + 0, + 'H5 die Tageskonfiguration ist mit ihrem Entwurf gegangen — die Kaskade hat gewirkt, nicht nur nicht geworfen' +); + +select is( + ( + select count(*)::int from public.worksite_days + where id = '00000000-0000-4000-8000-0000008011a1' + ), + 1, + 'H6 die stabile TAGESIDENTITAET ueberlebt das Loeschen des Entwurfs — sie haengt nicht an der Planversion (R-01)' +); + +-- H7: die Gegenprobe zu G8/G9. Der Umhaenge-Riegel darf die EINE Spalte, die der +-- Plan als schreibbar vorsieht, nicht mitsperren — sonst waere der Riegel kein +-- Riegel, sondern eine Totalsperre, und B3 (update-Grant nur auf lock_version) +-- liefe ins Leere. +select lives_ok( + $$update public.worksite_day_configurations + set lock_version = lock_version + 1 + where id = '00000000-0000-4000-8000-0000009010a1'$$, + 'H7 lock_version bleibt im ENTWURF schreibbar — der Umhaenge-Riegel trifft nur worksite_day_id und plan_version_id' +); + +select is( + ( + select lock_version from public.worksite_day_configurations + where id = '00000000-0000-4000-8000-0000009010a1' + ), + 1, + 'H8 der Zaehler ist wirklich gestiegen — lives_ok allein sagt nicht, dass etwas passiert ist' +); + +-- =========================================================================== +-- J. Kanalgrenze der neuen Funktionen — hier NUR negativ pruefbar +-- =========================================================================== +-- Diese Sitzung ist session_user = postgres und erfuellt app.is_runtime_channel() +-- nie. Die Zusicherungen belegen deshalb genau eines: der Kanalriegel steht VORNE +-- und laesst nichts an sich vorbei. Die dahinterliegenden Zweige (P0002 bei +-- fremder Konfiguration, 23514 bei veroeffentlichter Zuweisung, der positive +-- Kanalfall) sind hier UNERREICHBAR und liegen in M3/M4. + +select ok( + not app.is_runtime_channel(), + 'J0 diese Sitzung ist NICHT der Laufzeitkanal — die Voraussetzung von J1 bis J3' +); + +select throws_ok( + $$select app.remove_assignment_from_worksite_day( + (select id from public.assignments + where worksite_day_configuration_id = '00000000-0000-4000-8000-0000009010a1' + order by starts_at_utc limit 1), + '00000000-0000-4000-8000-0000009010a1')$$, + '42501', + 'Nur der Laufzeitkanal darf Zuweisungen aus einem Baustellentag entfernen', + 'J1 app.remove_assignment_from_worksite_day weist einen fremden Kanal ab, BEVOR sie irgendetwas ueber die Zuweisung sagt' +); + +select throws_ok( + $$select app.read_idempotency_result('planning.create_assignment', 'k1')$$, + '42501', + 'Kein Payload-Vorgang', + 'J2 (P4d) app.read_idempotency_result laesst nur die beiden Payload-Vorgaenge zu — jeder andere Operationsname ist kein Leseweg' +); + +select throws_ok( + $$select app.read_idempotency_result('planning.plan_worksite_day', 'k1')$$, + '42501', + 'Nur der Laufzeitkanal darf ein Replay-Ergebnis lesen', + 'J3 (P4a) app.read_idempotency_result weist einen fremden Kanal ab — und zwar NACH der Operationspruefung, weshalb J2 und J3 verschiedene Wortlaute haben' +); + +select throws_ok( + $$select app.lock_week_draft('2026-W44')$$, + '42501', + 'Nur der Laufzeitkanal darf einen Wochenentwurf sperren', + 'J4 app.lock_week_draft weist einen fremden Kanal ab — der Riegel steht vor jeder Mandantenaufloesung' +); + +-- =========================================================================== +-- K. Payloadpflicht der beiden neuen Vorgaenge +-- =========================================================================== +-- Der Check ist eine Datenbankregel, kein Testversprechen: ein payloadloser +-- Insert der neuen Operationen scheitert, statt einen Zustand zu hinterlassen, +-- in dem ein Replay weder reproduzieren noch ehrlich scheitern koennte. + +select throws_ok( + $$insert into public.idempotency_records + (org_id, operation, idempotency_key, subject_id, request_fingerprint) + values ('00000000-0000-4000-8000-0000000000a1', 'planning.plan_worksite_day', + 'k-ohne-payload', '00000000-0000-4000-8000-0000009010a1', 'fp1')$$, + '23514', + NULL, + 'K1 planning.plan_worksite_day ohne result_payload wird abgewiesen — die Pflicht steht in der Datenbank, nicht nur im Test' +); + +select lives_ok( + $$insert into public.idempotency_records + (org_id, operation, idempotency_key, subject_id, request_fingerprint) + values ('00000000-0000-4000-8000-0000000000a1', 'planning.create_assignment', + 'k-bestand', '00000000-0000-4000-8000-0000007010a1', 'fp2')$$, + 'K2 ein BESTANDSVORGANG bleibt ohne result_payload schreibbar — die Pflicht ist operationsgebunden, nicht pauschal' +); + +select throws_ok( + $$insert into public.idempotency_records + (org_id, operation, idempotency_key, subject_id, request_fingerprint, result_payload) + values ('00000000-0000-4000-8000-0000000000a1', 'planning.update_worksite_day_team', + 'k-kein-objekt', '00000000-0000-4000-8000-0000009010a1', 'fp3', '[]'::jsonb)$$, + '23514', + NULL, + 'K3 ein result_payload muss ein JSON-OBJEKT sein — ein Array wird abgewiesen' +); + +select lives_ok( + $$insert into public.idempotency_records + (org_id, operation, idempotency_key, subject_id, request_fingerprint, result_payload) + values ('00000000-0000-4000-8000-0000000000a1', 'planning.update_worksite_day_team', + 'k-mit-payload', '00000000-0000-4000-8000-0000009010a1', 'fp4', + '{"worksiteDayId":"00000000-0000-4000-8000-0000008010a1"}'::jsonb)$$, + 'K4 mit einem Objekt-Payload gelingt der Insert des neuen Vorgangs' +); + +select * from finish(); +rollback;