diff --git a/apps/api/src/modules/planning/application/planning-queries.port.ts b/apps/api/src/modules/planning/application/planning-queries.port.ts index 70360cf4..42ef9eb7 100644 --- a/apps/api/src/modules/planning/application/planning-queries.port.ts +++ b/apps/api/src/modules/planning/application/planning-queries.port.ts @@ -61,6 +61,8 @@ export interface SourceVersionRow { } export interface PlanningWindowRow { + /** Additive Tagesprojektion; Legacy-Einplanungen werden nicht adoptiert. */ + readonly worksiteDays?: readonly WorksiteDayReadRow[]; readonly weekKey: string; /** IANA-Zone der Organisation aus `organizations.time_zone` (Migration 0004). */ readonly timeZone: string; @@ -86,6 +88,21 @@ export interface PlanningWindowRow { readonly resources: ResourcesRow; } +/** Persistierter Baustellentag mit der Konfiguration des gelesenen Stands. */ +export interface WorksiteDayReadRow { + readonly worksiteDayId: string; + readonly configurationId: string; + readonly worksiteId: string; + readonly localDate: string; + readonly lockVersion: number; + readonly team: readonly { + readonly assignmentId: string; + readonly employeeId: string; + readonly startsAtUtc: Date; + readonly endsAtUtc: Date; + }[]; +} + /** Warum eine Leseanfrage nicht beantwortbar ist. */ export type PlanningQueryProblem = /** Angemeldet, aber ohne aktive Mitgliedschaft. */ diff --git a/apps/api/src/modules/planning/application/planning-writes.port.ts b/apps/api/src/modules/planning/application/planning-writes.port.ts index cd89e4d3..8cc3f914 100644 --- a/apps/api/src/modules/planning/application/planning-writes.port.ts +++ b/apps/api/src/modules/planning/application/planning-writes.port.ts @@ -22,6 +22,24 @@ export interface CreatedAssignmentRow { readonly endsAtUtc: Date; } +/** Eine angelegte Zeile des Einsatzteams eines Baustellentags. */ +export interface WorksiteDayTeamMemberRow { + readonly assignmentId: string; + readonly employeeId: string; + readonly startsAtUtc: Date; + readonly endsAtUtc: Date; +} + +/** Baustellentag-Identitaet und ihre Konfiguration in genau einem Entwurf. */ +export interface CreatedWorksiteDayRow { + readonly worksiteDayId: string; + readonly configurationId: string; + readonly worksiteId: string; + readonly localDate: string; + readonly lockVersion: number; + readonly team: readonly WorksiteDayTeamMemberRow[]; +} + /** Eine kollidierende Zuweisung, benannt, damit die Meldung konkret werden kann. */ export interface OverlapRow { readonly assignmentId: string; @@ -61,6 +79,10 @@ export type PlanningWriteProblem = * "angelegt" fuer etwas, das er nie geschickt hat. */ | { readonly kind: "IDEMPOTENCY_KEY_REUSED" } + /** Die stabile Tagesidentitaet hat in diesem Entwurf bereits eine Konfiguration. */ + | { readonly kind: "DUPLICATE_WORKSITE_DAY" } + /** Mindestens ein Teamintervall verlaesst den angefragten lokalen Baustellentag. */ + | { readonly kind: "INTERVAL_OUTSIDE_DAY" } /** * Der Client arbeitete auf einem anderen Stand als dem, der jetzt gilt * (EYT-107). `aktuelleVersionId` ist `null`, wenn es fuer die Woche @@ -125,6 +147,23 @@ export interface CreateAssignmentInput { readonly idempotencyKey: string; } +export interface PlanWorksiteDayInput { + readonly weekKey: string; + readonly worksiteId: string; + /** ISO-Kalendertag, ausgelegt in der Zeitzone der Organisation. */ + readonly localDate: string; + readonly team: readonly { + readonly employeeId: string; + readonly startsAtUtc: Date; + readonly endsAtUtc: Date; + }[]; + readonly idempotencyKey: string; +} + +export type PlanWorksiteDayResult = + | { readonly ok: true; readonly worksiteDay: CreatedWorksiteDayRow; readonly replayed: boolean } + | { readonly ok: false; readonly problem: PlanningWriteProblem }; + export interface PlanningWrites { /** * Prueft einen Entwurf gegen den Bestand — OHNE Schreibwirkung. @@ -145,6 +184,12 @@ export interface PlanningWrites { */ createAssignment(input: CreateAssignmentInput): Promise; + /** + * Legt einen Baustellentag mit seiner revisionsgebundenen Konfiguration und + * dem vollständigen Einsatzteam in einer Transaktion an (EYT-152). + */ + planWorksiteDay(input: PlanWorksiteDayInput): Promise; + /** * Veroeffentlicht den Entwurf einer Woche — atomar oder gar nicht (EYT-107). * diff --git a/apps/api/src/modules/planning/index.ts b/apps/api/src/modules/planning/index.ts index d9cee78b..b8bde84a 100644 --- a/apps/api/src/modules/planning/index.ts +++ b/apps/api/src/modules/planning/index.ts @@ -64,12 +64,16 @@ export type { CreateAssignmentInput, CreateAssignmentResult, CreatedAssignmentRow, + CreatedWorksiteDayRow, + PlanWorksiteDayInput, + PlanWorksiteDayResult, PlanningWriteProblem, PlanningWrites, PlanningWritesFactory, PublishPlanInput, PublishPlanResult, PublishedPlanVersionRow, + WorksiteDayTeamMemberRow, } from "./application/planning-writes.port"; export { PLANNING_WRITES_FACTORY } from "./application/planning-writes.port"; export { PlanningWriteRepository } from "./infrastructure/planning-write.repository"; diff --git a/apps/api/src/modules/planning/infrastructure/planning-window.repository.ts b/apps/api/src/modules/planning/infrastructure/planning-window.repository.ts index bd0b8990..8b85b874 100644 --- a/apps/api/src/modules/planning/infrastructure/planning-window.repository.ts +++ b/apps/api/src/modules/planning/infrastructure/planning-window.repository.ts @@ -44,6 +44,7 @@ import type { PublishedVersionsResult, ResourceRow, ResourcesRow, + WorksiteDayReadRow, } from "../application/planning-queries.port"; import type { QueryResult, @@ -62,14 +63,6 @@ interface VersionRow { readonly created_at: Date; } -interface RawAssignmentRow { - readonly id: string; - readonly employee_id: string; - readonly worksite_id: string; - readonly starts_at_utc: Date; - readonly ends_at_utc: Date; -} - /** * Zeile der Bereichsabfrage (EYT-109). * @@ -139,8 +132,14 @@ export class PlanningWindowRepository implements PlanningQueries { // sonst nichts (Entscheidungsnotiz, Punkt 2). const source = draft ?? latestPublished; - const assignments = - source === null || source === undefined ? [] : await this.assignmentsOf(tx, source.id); + // Zuweisungen UND Baustellentage aus EINEM Statement — ein Snapshot. + // Der Runner faehrt READ COMMITTED; zwei Statements saehen zwei + // Zeitpunkte, und ein dazwischen committeter Baustellentag lieferte ein + // Team, dessen Zuweisungen der Vertrag im Fenster nicht findet (EYT-158). + const { assignments, worksiteDays } = + source === null || source === undefined + ? { assignments: [], worksiteDays: [] } + : await this.planstandOf(tx, source.id, org.id); // Stammdaten im SELBEN Transaktionsblock wie Woche, Versionen und // Zuweisungen. Nicht aus Bequemlichkeit: eine zweite Route oder ein @@ -162,6 +161,7 @@ export class PlanningWindowRepository implements PlanningQueries { weekKey, timeZone: org.time_zone, assignments, + worksiteDays, resources, sourceVersion: source === null || source === undefined @@ -299,7 +299,10 @@ export class PlanningWindowRepository implements PlanningQueries { return { ok: false as const, problem: "PLAN_NOT_PUBLISHED" as const }; } - const assignments = await this.assignmentsOf(tx, version.id); + // Derselbe Ein-Snapshot-Lesepfad wie im Fenster; dieser Vertrag traegt + // (noch) keine Baustellentage, die Zuweisungen kommen aber aus demselben + // Statement wie dort. + const { assignments } = await this.planstandOf(tx, version.id, organisation.org.id); const resources = await this.resourcesOf(tx, organisation.org.id); return { @@ -413,7 +416,19 @@ export class PlanningWindowRepository implements PlanningQueries { } /** - * Die Zuweisungen EINER Planversion — als einzige Abfrage ohne `org_id`. + * Zuweisungen und Baustellentage EINER Planversion — ein Statement, ein + * Snapshot (EYT-158). + * + * ## Warum ein einziges Statement + * + * `PgTenantQueryRunner` oeffnet die Transaktion mit einem nackten `begin`, + * also READ COMMITTED: jedes Statement sieht seinen eigenen Snapshot. Zwei + * Statements koennten einen Baustellentag liefern, dessen Teammitglieder in + * der Zuweisungsliste noch fehlen — `PlanningWindowSchema` verwirft genau + * das, und aus einem gueltigen Serverstand wuerde ein 500. Der statische + * Waechter `planning-window-single-snapshot.test.ts` zaehlt die Statements. + * + * ## Warum die Zuweisungen KEINEN `org_id`-Filter tragen * * Das ist kein Vergessen. `assignments` traegt den tenantgebundenen * Fremdschluessel `(plan_version_id, org_id) references plan_versions (id, @@ -422,22 +437,131 @@ export class PlanningWindowRepository implements PlanningQueries { * gefunden hat. Ein zusaetzliches `and org_id = $2` waere damit eine * Bedingung, die keine Datenlage je verletzt — also keine Pruefung, sondern * Dekoration. Dieselbe Begruendung wie beim weggelassenen - * `published_at`-Filter oben. + * `published_at`-Filter oben. Die Tageskonfigurationen werden dagegen ueber + * `org_id` UND Version gelesen, weil ihr Tag (`worksite_days`) mandantenweit + * und versionslos ist. + * + * ## Reihenfolge + * + * Zuweisungen wie bisher nach `starts_at_utc, id`; Tage nach `local_date, + * worksite_day_id`; Teams nach `employee_id, starts_at_utc, id`. Sortiert + * wird hier, nicht in SQL: ein FULL OUTER JOIN hat keine Sortierung, die fuer + * beide Seiten zugleich richtig waere. */ - private async assignmentsOf(tx: TenantQuery, planVersionId: string): Promise { - const rows = await tx.query( - `select id, employee_id, worksite_id, starts_at_utc, ends_at_utc - from public.assignments - where plan_version_id = $1 - order by starts_at_utc asc, id asc`, - [planVersionId], + private async planstandOf( + tx: TenantQuery, + planVersionId: string, + orgId: string, + ): Promise<{ assignments: AssignmentRow[]; worksiteDays: WorksiteDayReadRow[] }> { + const rows = await tx.query<{ + configuration_id: string | null; + lock_version: number | null; + worksite_day_id: string | null; + day_worksite_id: string | null; + local_date: string | null; + assignment_id: string | null; + employee_id: string | null; + worksite_id: string | null; + starts_at_utc: Date | null; + ends_at_utc: Date | null; + }>( + `with tage as ( + select c.id as configuration_id, c.lock_version, + d.id as worksite_day_id, d.worksite_id as day_worksite_id, + to_char(d.local_date, 'YYYY-MM-DD') as local_date + 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.plan_version_id = $1 and c.org_id = $2 + ), zuweisungen as ( + select id as assignment_id, employee_id, worksite_id, starts_at_utc, ends_at_utc, + worksite_day_configuration_id + from public.assignments + where plan_version_id = $1 + ) + select t.configuration_id, t.lock_version, t.worksite_day_id, t.day_worksite_id, t.local_date, + z.assignment_id, z.employee_id, z.worksite_id, z.starts_at_utc, z.ends_at_utc + from tage t + full outer join zuweisungen z on z.worksite_day_configuration_id = t.configuration_id`, + [planVersionId, orgId], ); - return rows.rows.map((row) => ({ - id: row.id, - employeeId: row.employee_id, - worksiteId: row.worksite_id, - startsAtUtc: row.starts_at_utc, - endsAtUtc: row.ends_at_utc, - })); + + const assignments: AssignmentRow[] = []; + const days = new Map< + string, + Omit & { team: WorksiteDayReadRow["team"][number][] } + >(); + for (const row of rows.rows) { + if ( + row.assignment_id !== null && + row.employee_id !== null && + row.worksite_id !== null && + row.starts_at_utc !== null && + row.ends_at_utc !== null + ) { + assignments.push({ + id: row.assignment_id, + employeeId: row.employee_id, + worksiteId: row.worksite_id, + startsAtUtc: row.starts_at_utc, + endsAtUtc: row.ends_at_utc, + }); + } + if ( + row.configuration_id === null || + row.lock_version === null || + row.worksite_day_id === null || + row.day_worksite_id === null || + row.local_date === null + ) { + continue; + } + let day = days.get(row.configuration_id); + if (day === undefined) { + day = { + worksiteDayId: row.worksite_day_id, + configurationId: row.configuration_id, + worksiteId: row.day_worksite_id, + localDate: row.local_date, + lockVersion: row.lock_version, + team: [], + }; + days.set(row.configuration_id, day); + } + if ( + row.assignment_id !== null && + row.employee_id !== null && + row.starts_at_utc !== null && + row.ends_at_utc !== null + ) { + day.team.push({ + assignmentId: row.assignment_id, + employeeId: row.employee_id, + startsAtUtc: row.starts_at_utc, + endsAtUtc: row.ends_at_utc, + }); + } + } + + assignments.sort( + (a, b) => + a.startsAtUtc.getTime() - b.startsAtUtc.getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + const worksiteDays = [...days.values()] + .map((day) => ({ + ...day, + team: [...day.team].sort( + (a, b) => + (a.employeeId < b.employeeId ? -1 : a.employeeId > b.employeeId ? 1 : 0) || + a.startsAtUtc.getTime() - b.startsAtUtc.getTime() || + (a.assignmentId < b.assignmentId ? -1 : a.assignmentId > b.assignmentId ? 1 : 0), + ), + })) + .sort( + (a, b) => + (a.localDate < b.localDate ? -1 : a.localDate > b.localDate ? 1 : 0) || + (a.worksiteDayId < b.worksiteDayId ? -1 : a.worksiteDayId > b.worksiteDayId ? 1 : 0), + ); + return { assignments, worksiteDays }; } } diff --git a/apps/api/src/modules/planning/infrastructure/planning-write.repository.ts b/apps/api/src/modules/planning/infrastructure/planning-write.repository.ts index 29ddc3d0..89966a9f 100644 --- a/apps/api/src/modules/planning/infrastructure/planning-write.repository.ts +++ b/apps/api/src/modules/planning/infrastructure/planning-write.repository.ts @@ -35,7 +35,10 @@ */ import { createTimeZone, + isoWeekOfLocalDate, + localBusinessDate, NO_CAPACITY_LIMIT, + planningWeekKey, TimeInterval, unsafeIdentifier, type AssignmentId, @@ -48,6 +51,9 @@ import type { CreateAssignmentInput, CreateAssignmentResult, CreatedAssignmentRow, + CreatedWorksiteDayRow, + PlanWorksiteDayInput, + PlanWorksiteDayResult, PlanningWrites, PublishPlanInput, PublishPlanResult, @@ -122,6 +128,22 @@ interface AssignmentInsertRow { readonly ends_at_utc: Date; } +interface WorksiteDayRow { + readonly id: string; +} + +interface WorksiteDayConfigurationRow { + readonly id: string; + readonly lock_version: number; +} + +interface WorksiteDayAssignmentInsertRow { + readonly id: string; + readonly employee_id: string; + readonly starts_at_utc: Date; + readonly ends_at_utc: Date; +} + interface OverlapQueryRow { readonly id: string; readonly starts_at_utc: Date; @@ -174,6 +196,9 @@ const OPERATION = "planning.create_assignment"; */ const PUBLISH_OPERATION = "planning.publish_plan"; +/** Eigener Idempotenzraum; das Ergebnis ist nur ueber 0019 lesbar. */ +const PLAN_WORKSITE_DAY_OPERATION = "planning.plan_worksite_day"; + /** * Fingerabdruck der Veroeffentlichungsanfrage. * @@ -187,6 +212,130 @@ function publishFingerprintOf(input: PublishPlanInput): string { return [input.weekKey, input.expectedVersionId ?? "null"].join("|"); } +function worksiteDayFingerprintOf(input: PlanWorksiteDayInput): string { + return [ + input.weekKey, + input.worksiteId, + input.localDate, + ...input.team + .map((member) => + [member.employeeId, member.startsAtUtc.toISOString(), member.endsAtUtc.toISOString()].join( + "|", + ), + ) + .sort(), + ].join("|"); +} + +function localDateParts(localDate: string): { year: number; month: number; day: number } { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(localDate); + if (match === null) + throw new Error("EYT-152: ungueltiges lokales Datum erreichte das Repository."); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const probe = new Date(Date.UTC(year, month - 1, day)); + if ( + probe.getUTCFullYear() !== year || + probe.getUTCMonth() !== month - 1 || + probe.getUTCDate() !== day + ) { + throw new Error("EYT-152: nicht existentes lokales Datum erreichte das Repository."); + } + return { year, month, day }; +} + +function isSameLocalDate( + a: { year: number; month: number; day: number }, + b: { year: number; month: number; day: number }, +): boolean { + return a.year === b.year && a.month === b.month && a.day === b.day; +} + +function serialisiereWorksiteDay(tag: CreatedWorksiteDayRow): Record { + return { + worksiteDayId: tag.worksiteDayId, + configurationId: tag.configurationId, + worksiteId: tag.worksiteId, + localDate: tag.localDate, + lockVersion: tag.lockVersion, + team: tag.team.map((member) => ({ + assignmentId: member.assignmentId, + employeeId: member.employeeId, + interval: { + startUtc: member.startsAtUtc.toISOString(), + endUtc: member.endsAtUtc.toISOString(), + }, + })), + }; +} + +function replayWorksiteDay(payload: unknown): CreatedWorksiteDayRow { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("EYT-152: Idempotenz-Payload eines Baustellentags ist unlesbar."); + } + const record = payload as Record; + const requiredString = (field: string): string => { + const value = record[field]; + if (typeof value !== "string") { + throw new Error(`EYT-152: Idempotenz-Payload ohne ${field}.`); + } + return value; + }; + if (!Number.isInteger(record["lockVersion"]) || (record["lockVersion"] as number) < 0) { + throw new Error("EYT-152: Idempotenz-Payload ohne gueltige lockVersion."); + } + if (!Array.isArray(record["team"])) { + throw new Error("EYT-152: Idempotenz-Payload ohne Team."); + } + return { + worksiteDayId: requiredString("worksiteDayId"), + configurationId: requiredString("configurationId"), + worksiteId: requiredString("worksiteId"), + localDate: requiredString("localDate"), + lockVersion: record["lockVersion"] as number, + team: record["team"].map((raw) => { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("EYT-152: Idempotenz-Payload mit unlesbarem Teammitglied."); + } + const member = raw as Record; + const interval = member["interval"]; + if (typeof interval !== "object" || interval === null || Array.isArray(interval)) { + throw new Error("EYT-152: Idempotenz-Payload mit unlesbarem Intervall."); + } + const values = interval as Record; + if ( + typeof member["assignmentId"] !== "string" || + typeof member["employeeId"] !== "string" || + typeof values["startUtc"] !== "string" || + typeof values["endUtc"] !== "string" + ) { + throw new Error("EYT-152: Idempotenz-Payload mit unvollstaendigem Teammitglied."); + } + const startsAtUtc = new Date(values["startUtc"]); + const endsAtUtc = new Date(values["endUtc"]); + if (Number.isNaN(startsAtUtc.getTime()) || Number.isNaN(endsAtUtc.getTime())) { + throw new Error("EYT-152: Idempotenz-Payload mit ungueltigem Zeitstempel."); + } + return { + assignmentId: member["assignmentId"], + employeeId: member["employeeId"], + startsAtUtc, + endsAtUtc, + }; + }), + }; +} + +/** Fachliche Ablehnung NACH einer Mutation: erzwingt den Transaktionsrollback. */ +class WorksiteDayRejectedError extends Error { + constructor( + readonly problem: import("../application/planning-writes.port").PlanningWriteProblem, + ) { + super(`EYT-152: ${problem.kind}`); + } +} + function zuAssignment(zeile: AssignmentInsertRow): CreatedAssignmentRow { return { id: zeile.id, @@ -504,26 +653,18 @@ export class PlanningWriteRepository implements PlanningWrites { // Version. `published_at` bleibt dabei NULL: die Kopien sind Entwurf, // nicht veroeffentlicht, und die Exclusion-Constraint aus 0010 greift // fuer sie bewusst nicht. + // + // Seit EYT-158 ist die Kopie fuer BEIDE Schreibpfade dieselbe: erst die + // Tageskonfigurationen (stabile Tagesidentitaet, lock_version bleibt), + // dann die Zuweisungen, an die kopierte Revision gehaengt. Sonst hinge + // es vom ersten Klick nach dem Publish ab, ob ein Baustellentag im + // Folgedraft eine Karte bleibt oder in Legacy-Zeilen zerfaellt. if (selbstAngelegt !== undefined) { - await tx.query( - `insert into public.assignments - (org_id, plan_version_id, employee_id, worksite_id, starts_at_utc, ends_at_utc) - select a.org_id, $1, a.employee_id, a.worksite_id, a.starts_at_utc, a.ends_at_utc - from public.assignments a - join public.plan_versions pv on pv.id = a.plan_version_id - where pv.week_key = $2 - and pv.published_at is not null - and pv.id = ( - select pv2.id from public.plan_versions pv2 - where pv2.week_key = $2 and pv2.published_at is not null - order by pv2.published_at asc, pv2.created_at asc, pv2.id asc - limit 1 offset ( - select greatest(count(*) - 1, 0) from public.plan_versions pv3 - where pv3.week_key = $2 and pv3.published_at is not null - ) - )`, - [entwurf.id, input.weekKey], - ); + const predecessorId = await this.zuletztVeroeffentlichte(tx, org.id, input.weekKey); + if (predecessorId !== undefined) { + await this.kopiereTageskonfigurationen(tx, entwurf.id, predecessorId, org.id); + await this.kopiereZuweisungen(tx, entwurf.id, predecessorId, org.id); + } } const kollisionen = await tx.query( @@ -633,6 +774,301 @@ export class PlanningWriteRepository implements PlanningWrites { }); } + /** + * Plant einen Baustellentag als EINE atomare Wirkung (EYT-152). + * + * `worksite_days` ist die stabile Identitaet, die Konfiguration gehoert + * dagegen genau zu dem durch `app.lock_week_draft` ermittelten Entwurf. Die + * Funktion ist bewusst der einzige Erwerb des Klassen-2-Wochenlocks: ein + * eigener Select/Insert-Pfad wuerde die in Migration 0019 bewiesene + * Nebenlaeufigkeitsgrenze wieder auf Anwendungscode verteilen. + */ + async planWorksiteDay(input: PlanWorksiteDayInput): Promise { + try { + return await this.runner.run({ userId: this.subjectUserId }, async (tx) => { + const fingerabdruck = worksiteDayFingerprintOf(input); + + // Replay wird vor jeder veraenderlichen Beobachtung beantwortet. Der + // Ergebnislesepfad ist absichtlich die security-definer-Funktion aus + // 0019; `result_payload` besitzt kein Tabellen-SELECT-Grant. + await this.idempotenz.lock(tx, PLAN_WORKSITE_DAY_OPERATION, input.idempotencyKey); + const bekannt = await this.idempotenz.find( + tx, + PLAN_WORKSITE_DAY_OPERATION, + input.idempotencyKey, + ); + if (bekannt !== null) { + if (bekannt.requestFingerprint !== fingerabdruck) { + return { ok: false as const, problem: { kind: "IDEMPOTENCY_KEY_REUSED" as const } }; + } + const payload = await this.idempotenz.readResultPayload( + tx, + PLAN_WORKSITE_DAY_OPERATION, + input.idempotencyKey, + ); + if (payload === null) { + throw new Error("EYT-152: Idempotenzschluessel ohne Replay-Ergebnis."); + } + return { ok: true as const, worksiteDay: replayWorksiteDay(payload), replayed: true }; + } + + const orgs = await tx.query("select id, time_zone from public.organizations"); + if (orgs.rows.length === 0) + return { ok: false as const, problem: { kind: "NO_ORGANISATION" as const } }; + if (orgs.rows.length > 1) + return { ok: false as const, problem: { kind: "AMBIGUOUS_ORGANISATION" as const } }; + const org = orgs.rows[0]; + if (org === undefined) + return { ok: false as const, problem: { kind: "NO_ORGANISATION" as const } }; + + const zone = createTimeZone(org.time_zone); + if (!zone.ok) { + throw new Error(`EYT-152: unbekannte Zeitzone "${org.time_zone}" in organizations.`); + } + const requestedDay = localDateParts(input.localDate); + const requestedWeek = planningWeekKey(isoWeekOfLocalDate(requestedDay)); + if (requestedWeek !== input.weekKey) { + return { + ok: false as const, + problem: { kind: "OUTSIDE_WEEK" as const, tatsaechlicheWoche: requestedWeek }, + }; + } + + for (const member of input.team) { + const interval = TimeInterval.create(member.startsAtUtc, member.endsAtUtc); + if (!interval.ok) { + throw new Error( + `EYT-152: ungueltiges Intervall erreichte die Domain (${interval.error}).`, + ); + } + // Halb-offen: ein Ende exakt am Folgetag um 00:00 gehoert noch zum + // angefragten lokalen Tag. Ein Anfang oder ein letztes enthaltenes + // Millisekundenfragment ausserhalb dagegen nicht. + const startsOnRequestedDay = localBusinessDate(member.startsAtUtc, zone.timeZone); + const endsOnRequestedDay = localBusinessDate( + new Date(member.endsAtUtc.getTime() - 1), + zone.timeZone, + ); + if ( + !isSameLocalDate(startsOnRequestedDay, requestedDay) || + !isSameLocalDate(endsOnRequestedDay, requestedDay) + ) { + return { ok: false as const, problem: { kind: "INTERVAL_OUTSIDE_DAY" as const } }; + } + } + + const worksite = await tx.query( + "select active from public.worksites where id = $1", + [input.worksiteId], + ); + if (worksite.rows[0] === undefined || !worksite.rows[0].active) { + return { + ok: false as const, + problem: { kind: "RESOURCE_NOT_SELECTABLE" as const, feld: "worksiteId" as const }, + }; + } + for (const employeeId of [ + ...new Set(input.team.map((member) => member.employeeId)), + ].sort()) { + const employee = await tx.query( + "select active from public.employees where id = $1", + [employeeId], + ); + if (employee.rows[0] === undefined || !employee.rows[0].active) { + return { + ok: false as const, + problem: { kind: "RESOURCE_NOT_SELECTABLE" as const, feld: "employeeId" as const }, + }; + } + } + + // Der Datenbankvertrag erwirbt/erstellt den Draft und haelt ihn bis zum + // Commit. Kein zweiter Draft-Aufloesungspfad ist hier zulaessig. + const draft = await tx.query("select app.lock_week_draft($1) as id", [ + input.weekKey, + ]); + const draftId = draft.rows[0]?.id; + if (draftId === undefined) throw new Error("EYT-152: Wochenentwurf lieferte keine Id."); + + // Nur der INSERT-Gewinner übernimmt die veröffentlichte Baseline. + // Der Helfer gibt bewusst nur die ID zurück. xmin benennt die + // erzeugende Transaktion, ohne einen zweiten Draft-Auflösungspfad + // oder einen racy Vorab-Existenztest einzuführen. Der Runner benutzt + // eine Top-Level-Transaktion, keine Savepoints/Subtransaktionen. + const selbstAngelegt = await tx.query( + `select d.id from public.plan_versions d + where d.id = $1 and d.org_id = $2 and d.xmin = pg_current_xact_id()::xid`, + [draftId, org.id], + ); + const predecessorId = + selbstAngelegt.rows[0] === undefined + ? undefined + : await this.zuletztVeroeffentlichte(tx, org.id, input.weekKey); + + const insertedDay = await tx.query( + `insert into public.worksite_days (org_id, worksite_id, local_date) + values ($1, $2, $3::date) + on conflict (org_id, worksite_id, local_date) do nothing + returning id`, + [org.id, input.worksiteId, input.localDate], + ); + const worksiteDayId = + insertedDay.rows[0]?.id ?? + ( + await tx.query( + `select id from public.worksite_days + where worksite_id = $1 and local_date = $2::date`, + [input.worksiteId, input.localDate], + ) + ).rows[0]?.id; + if (worksiteDayId === undefined) { + throw new Error("EYT-152: stabile Baustellentag-Identitaet nicht lesbar."); + } + + // Klasse (3) ist abgeschlossen, bevor Konfigurationen (4) entstehen. + // Stabile Tagesidentität und lock_version bleiben erhalten; die + // Default-ID erzeugt eine NEUE Konfigurationsrevision (EYT-158). + if (predecessorId !== undefined) { + await this.kopiereTageskonfigurationen(tx, draftId, predecessorId, org.id); + } + + const configuration = await tx.query( + `insert into public.worksite_day_configurations (org_id, worksite_day_id, plan_version_id) + values ($1, $2, $3) + on conflict (plan_version_id, worksite_day_id) do nothing + returning id, lock_version`, + [org.id, worksiteDayId, draftId], + ); + const config = configuration.rows[0]; + if (config === undefined) { + throw new WorksiteDayRejectedError({ kind: "DUPLICATE_WORKSITE_DAY" }); + } + + // Globale Reihenfolge der Mitarbeiterlocks verhindert A->B / B->A- + // Deadlocks. Die Konfiguration liegt bereits in der Transaktion und + // verschwindet bei jedem Konflikt oder Fehler wieder mit ihr. + const inheritedEmployees = + predecessorId === undefined + ? [] + : ( + await tx.query<{ employee_id: string }>( + `select distinct employee_id from public.assignments + where plan_version_id = $1 and org_id = $2`, + [predecessorId, org.id], + ) + ).rows.map((row) => row.employee_id); + for (const employeeId of [ + ...new Set([...inheritedEmployees, ...input.team.map((member) => member.employeeId)]), + ].sort()) { + await tx.query("select app.lock_employee_planning($1)", [employeeId]); + } + + // Erst NACH allen sortierten Employee-Locks (5) folgen Inserts (6). + // Legacy-Einplanungen behalten NULL; keine Tag-Adoption/Backfills. + if (predecessorId !== undefined) { + await this.kopiereZuweisungen(tx, draftId, predecessorId, org.id); + } + + const team: Array = []; + for (const member of [...input.team].sort((a, b) => { + const byEmployee = a.employeeId.localeCompare(b.employeeId); + if (byEmployee !== 0) return byEmployee; + return a.startsAtUtc.getTime() - b.startsAtUtc.getTime(); + })) { + const kollision = await tx.query( + `select id, starts_at_utc, ends_at_utc + from public.assignments + where plan_version_id = $1 + and employee_id = $2 + and during && tstzrange($3::timestamptz, $4::timestamptz, '[)') + order by starts_at_utc asc, id asc + limit 1`, + [draftId, member.employeeId, member.startsAtUtc, member.endsAtUtc], + ); + const overlap = kollision.rows[0]; + if (overlap !== undefined) { + throw new WorksiteDayRejectedError({ + kind: "OVERLAPPING_ASSIGNMENT", + overlap: { + assignmentId: overlap.id, + startsAtUtc: overlap.starts_at_utc, + endsAtUtc: overlap.ends_at_utc, + }, + }); + } + + const assignment = await tx.query( + `insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, employee_id, worksite_id, starts_at_utc, ends_at_utc) + select pv.org_id, pv.id, $2, $3, $4, $5, $6 + from public.plan_versions pv + where pv.id = $1 + returning id, employee_id, starts_at_utc, ends_at_utc`, + [ + draftId, + config.id, + member.employeeId, + input.worksiteId, + member.startsAtUtc, + member.endsAtUtc, + ], + ); + const inserted = assignment.rows[0]; + if (inserted === undefined) + throw new Error("EYT-152: Teamzuweisung wurde nicht angelegt."); + team.push({ + assignmentId: inserted.id, + employeeId: inserted.employee_id, + startsAtUtc: inserted.starts_at_utc, + endsAtUtc: inserted.ends_at_utc, + }); + } + + const worksiteDay: CreatedWorksiteDayRow = { + worksiteDayId, + configurationId: config.id, + worksiteId: input.worksiteId, + localDate: input.localDate, + lockVersion: config.lock_version, + team, + }; + + await tx.query( + `insert into public.audit_events + (org_id, actor_user_id, event_type, subject_type, subject_id, context) + values ($1, app.current_user_id(), 'planning.worksite_day_planned', 'worksite_day', $2, $3::jsonb)`, + [ + org.id, + worksiteDayId, + JSON.stringify({ + weekKey: input.weekKey, + configurationId: config.id, + worksiteId: input.worksiteId, + localDate: input.localDate, + assignmentIds: team.map((member) => member.assignmentId), + }), + ], + ); + + await this.idempotenz.rememberWithResultPayload( + tx, + org.id, + PLAN_WORKSITE_DAY_OPERATION, + input.idempotencyKey, + worksiteDayId, + fingerabdruck, + serialisiereWorksiteDay(worksiteDay), + ); + return { ok: true as const, worksiteDay, replayed: false }; + }); + } catch (error) { + if (error instanceof WorksiteDayRejectedError) { + return { ok: false, problem: error.problem }; + } + throw error; + } + } + /** * Den Entwurf einer Woche veroeffentlichen (EYT-107). * @@ -674,6 +1110,82 @@ export class PlanningWriteRepository implements PlanningWrites { * 5. Ein `update`. * 6. Audit, Outbox, Idempotenzergebnis — dieselbe Transaktion. */ + /** + * Die zuletzt veroeffentlichte Version einer Woche — die Baseline, die ein + * frisch angelegter Folgedraft uebernimmt. Dieselbe Ordnung wie in der + * Leseroute (`published_at, created_at, id`), damit Schreib- und Lesepfad + * denselben Vorgaenger meinen. + */ + private async zuletztVeroeffentlichte( + tx: TenantQuery, + orgId: string, + weekKey: string, + ): Promise { + const zeilen = await tx.query( + `select id from public.plan_versions + where org_id = $1 and week_key = $2 and published_at is not null + order by published_at desc, created_at desc, id desc + limit 1`, + [orgId, weekKey], + ); + return zeilen.rows[0]?.id; + } + + /** + * Baseline-Kopie, Teil 1 (EYT-158): die Tageskonfigurationen des Vorgaengers + * als NEUE Revisionen im Folgedraft. Die stabile Tagesidentitaet + * (`worksite_day_id`) und `lock_version` bleiben; die Default-Id erzeugt die + * neue Revision. Laeuft VOR dem Anlegen eigener Konfigurationen, damit ein im + * Vorgaenger vorhandener Tag im Folgedraft als Duplikat erkannt wird. + */ + private async kopiereTageskonfigurationen( + tx: TenantQuery, + draftId: string, + predecessorId: string, + orgId: string, + ): Promise { + await tx.query( + `insert into public.worksite_day_configurations + (org_id, plan_version_id, worksite_day_id, lock_version) + select org_id, $1, worksite_day_id, lock_version + from public.worksite_day_configurations + where plan_version_id = $2 and org_id = $3 + order by worksite_day_id`, + [draftId, predecessorId, orgId], + ); + } + + /** + * Baseline-Kopie, Teil 2: die Zuweisungen des Vorgaengers, an die kopierte + * Revision desselben Tages gehaengt. Legacy-Einplanungen (ohne Tag) behalten + * NULL — keine Tag-Adoption, kein Backfill. `published_at` bleibt NULL: die + * Kopien sind Entwurf, und die Exclusion-Constraint aus 0010 greift fuer sie + * bewusst nicht. Muss NACH den Personensperren laufen, wo es welche gibt. + */ + private async kopiereZuweisungen( + tx: TenantQuery, + draftId: string, + predecessorId: string, + orgId: string, + ): Promise { + await tx.query( + `insert into public.assignments + (org_id, plan_version_id, worksite_day_configuration_id, + employee_id, worksite_id, starts_at_utc, ends_at_utc) + select a.org_id, $1, copied.id, a.employee_id, a.worksite_id, + a.starts_at_utc, a.ends_at_utc + from public.assignments a + left join public.worksite_day_configurations original + on original.id = a.worksite_day_configuration_id and original.org_id = a.org_id + left join public.worksite_day_configurations copied + on copied.worksite_day_id = original.worksite_day_id + and copied.org_id = a.org_id and copied.plan_version_id = $1 + where a.plan_version_id = $2 and a.org_id = $3 + order by a.employee_id, a.starts_at_utc, a.id`, + [draftId, predecessorId, orgId], + ); + } + async publishPlan(input: PublishPlanInput): Promise { const fingerabdruck = publishFingerprintOf(input); try { diff --git a/apps/api/src/modules/planning/interface/http/planning-problem.filter.ts b/apps/api/src/modules/planning/interface/http/planning-problem.filter.ts index e9491d85..e40c38a7 100644 --- a/apps/api/src/modules/planning/interface/http/planning-problem.filter.ts +++ b/apps/api/src/modules/planning/interface/http/planning-problem.filter.ts @@ -26,6 +26,7 @@ import { Catch, HttpException, HttpStatus } from "@nestjs/common"; import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; import type { Request, Response } from "express"; import { STATUS_CODES } from "node:http"; +import { WORKSITE_DAY_PROBLEM_TYPE } from "@easytree/contracts"; /** * Die stabilen Fehlercodes des Planungs-Schreibpfads. @@ -63,7 +64,10 @@ export const PLANNING_ERROR_TYPE = { export type PlanningErrorType = (typeof PLANNING_ERROR_TYPE)[keyof typeof PLANNING_ERROR_TYPE]; -const BEKANNTE_TYPEN = new Set(Object.values(PLANNING_ERROR_TYPE)); +const BEKANNTE_TYPEN = new Set([ + ...Object.values(PLANNING_ERROR_TYPE), + ...Object.values(WORKSITE_DAY_PROBLEM_TYPE), +]); const GENERIC_DETAIL = "An unexpected error occurred."; diff --git a/apps/api/src/modules/planning/interface/http/planning.controller.ts b/apps/api/src/modules/planning/interface/http/planning.controller.ts index 78a0398d..9f91019d 100644 --- a/apps/api/src/modules/planning/interface/http/planning.controller.ts +++ b/apps/api/src/modules/planning/interface/http/planning.controller.ts @@ -49,11 +49,14 @@ import { IDEMPOTENCY_HEADER, IdempotencyKeySchema, PlanValidationResultSchema, + PlanWorksiteDayCommandSchema, PlanningWindowQuerySchema, PlanningWindowSchema, PublishPlanCommandSchema, PublishedPlanVersionSchema, ValidatePlanCommandSchema, + WORKSITE_DAY_PROBLEM_TYPE, + WorksiteDayDtoSchema, } from "@easytree/contracts"; import { BadRequestException, @@ -150,6 +153,19 @@ function problemFor(problem: PlanningWriteProblem): Error { detail: "Dieser Idempotenzschlüssel gehört zu einem anderen Einsatz. Für einen neuen Einsatz einen neuen Schlüssel verwenden; für einen Wiederholungsversuch dieselbe Anfrage senden.", }); + case "DUPLICATE_WORKSITE_DAY": + return new ConflictException({ + type: WORKSITE_DAY_PROBLEM_TYPE.DUPLICATE_WORKSITE_DAY, + title: "Baustellentag besteht bereits", + detail: + "Für diese Baustelle und diesen lokalen Tag existiert im aktuellen Entwurf bereits ein Baustellentag.", + }); + case "INTERVAL_OUTSIDE_DAY": + return new ConflictException({ + type: WORKSITE_DAY_PROBLEM_TYPE.INTERVAL_OUTSIDE_DAY, + title: "Arbeitszeit liegt außerhalb des Baustellentags", + detail: "Jedes Teamintervall muss vollständig im angefragten lokalen Baustellentag liegen.", + }); case "OUTSIDE_WEEK": return new BadRequestException({ type: PLANNING_ERROR_TYPE.OUTSIDE_WEEK, @@ -314,6 +330,21 @@ export class PlanningController { const body = { weekKey: result.window.weekKey, timeZone: result.window.timeZone, + worksiteDays: result.window.worksiteDays?.map((day) => ({ + worksiteDayId: day.worksiteDayId, + configurationId: day.configurationId, + worksiteId: day.worksiteId, + localDate: day.localDate, + lockVersion: day.lockVersion, + team: day.team.map((member) => ({ + assignmentId: member.assignmentId, + employeeId: member.employeeId, + interval: { + startUtc: member.startsAtUtc.toISOString(), + endUtc: member.endsAtUtc.toISOString(), + }, + })), + })), assignments: result.window.assignments.map((assignment) => ({ id: assignment.id, employeeId: assignment.employeeId, @@ -465,6 +496,79 @@ export class PlanningController { return validated.data; } + @Post("baustellentage") + @HttpCode(201) + async planWorksiteDay( + @Req() request: Request, + @Body() body: unknown, + @Headers(IDEMPOTENCY_HEADER) idempotencyKey?: string, + ): Promise { + const { subjectUserId: subject } = await this.zugang(request, "planning.write"); + + const schluessel = IdempotencyKeySchema.safeParse(idempotencyKey); + if (!schluessel.success) { + throw new BadRequestException({ + type: PLANNING_ERROR_TYPE.MISSING_IDEMPOTENCY_KEY, + title: "Idempotenzschlüssel fehlt oder ist unbrauchbar", + detail: `Der Header ${IDEMPOTENCY_HEADER} fehlt oder entspricht nicht dem Vertrag.`, + }); + } + + const command = PlanWorksiteDayCommandSchema.safeParse(body); + if (!command.success) { + const teamIsEmpty = + typeof body === "object" && + body !== null && + "team" in body && + Array.isArray((body as { team?: unknown }).team) && + (body as { team: unknown[] }).team.length === 0; + throw new BadRequestException({ + type: teamIsEmpty + ? WORKSITE_DAY_PROBLEM_TYPE.WORKSITE_DAY_TEAM_REQUIRED + : PLANNING_ERROR_TYPE.INVALID_INTERVAL, + title: teamIsEmpty + ? "Baustellentag benötigt ein Team" + : "Baustellentag unvollständig oder ungültig", + detail: teamIsEmpty + ? "Ein Baustellentag wird nicht ohne Team angelegt." + : "Woche, Baustelle, lokales Datum oder Teamintervall entsprechen nicht dem Vertrag.", + }); + } + + const result = await this.writesFor(subject).planWorksiteDay({ + weekKey: command.data.weekKey, + worksiteId: command.data.worksiteId, + localDate: command.data.localDate, + team: command.data.team.map((member) => ({ + employeeId: member.employeeId, + startsAtUtc: new Date(member.interval.startUtc), + endsAtUtc: new Date(member.interval.endUtc), + })), + idempotencyKey: schluessel.data, + }); + if (!result.ok) throw problemFor(result.problem); + + const response = WorksiteDayDtoSchema.safeParse({ + worksiteDayId: result.worksiteDay.worksiteDayId, + configurationId: result.worksiteDay.configurationId, + worksiteId: result.worksiteDay.worksiteId, + localDate: result.worksiteDay.localDate, + lockVersion: result.worksiteDay.lockVersion, + team: result.worksiteDay.team.map((member) => ({ + assignmentId: member.assignmentId, + employeeId: member.employeeId, + interval: { + startUtc: member.startsAtUtc.toISOString(), + endUtc: member.endsAtUtc.toISOString(), + }, + })), + }); + if (!response.success) { + throw new InternalServerErrorException("Antwort entspricht nicht dem Vertrag."); + } + return response.data; + } + /** * Den Entwurf einer Woche veroeffentlichen (EYT-107). * diff --git a/apps/api/src/platform/idempotency/idempotency-store.ts b/apps/api/src/platform/idempotency/idempotency-store.ts index 93a5e84e..2ae9da0e 100644 --- a/apps/api/src/platform/idempotency/idempotency-store.ts +++ b/apps/api/src/platform/idempotency/idempotency-store.ts @@ -48,6 +48,12 @@ export interface IdempotencyStore { /** Das Ergebnis eines frueheren Aufrufs, oder `null`. */ find(tx: TenantQuery, operation: string, key: string): Promise; + /** + * Liest einen historischen Antwortkoerper ausschliesslich ueber die + * operation-gebundene Datenbankfunktion. Nicht jeder Vorgang hat ein Payload. + */ + readResultPayload(tx: TenantQuery, operation: string, key: string): Promise; + /** Haelt das Ergebnis des ERSTEN Aufrufs fest. */ remember( tx: TenantQuery, @@ -57,6 +63,17 @@ export interface IdempotencyStore { subjectId: string, requestFingerprint: string, ): Promise; + + /** Speichert ein unveraenderliches Erstantwort-Payload im selben Commit. */ + rememberWithResultPayload( + tx: TenantQuery, + organisationId: string, + operation: string, + key: string, + subjectId: string, + requestFingerprint: string, + resultPayload: unknown, + ): Promise; } export const IDEMPOTENCY_STORE = "PLATFORM_IDEMPOTENCY_STORE"; diff --git a/apps/api/src/platform/idempotency/pg-idempotency-store.ts b/apps/api/src/platform/idempotency/pg-idempotency-store.ts index 0cda5f04..6060981b 100644 --- a/apps/api/src/platform/idempotency/pg-idempotency-store.ts +++ b/apps/api/src/platform/idempotency/pg-idempotency-store.ts @@ -33,6 +33,18 @@ export class PgIdempotencyStore implements IdempotencyStore { return { subjectId: zeile.subject_id, requestFingerprint: zeile.request_fingerprint }; } + async readResultPayload( + tx: TenantQuery, + operation: string, + key: string, + ): Promise { + const ergebnis = await tx.query<{ payload: unknown | null }>( + `select app.read_idempotency_result($1, $2) as payload`, + [operation, key], + ); + return ergebnis.rows[0]?.payload ?? null; + } + async remember( tx: TenantQuery, organisationId: string, @@ -48,4 +60,28 @@ export class PgIdempotencyStore implements IdempotencyStore { [organisationId, operation, key, subjectId, requestFingerprint], ); } + + async rememberWithResultPayload( + tx: TenantQuery, + organisationId: string, + operation: string, + key: string, + subjectId: string, + requestFingerprint: string, + resultPayload: unknown, + ): Promise { + await tx.query( + `insert into public.idempotency_records + (org_id, operation, idempotency_key, subject_id, request_fingerprint, result_payload) + values ($1::uuid, $2, $3, $4::uuid, $5, $6::jsonb)`, + [ + organisationId, + operation, + key, + subjectId, + requestFingerprint, + JSON.stringify(resultPayload), + ], + ); + } } diff --git a/apps/api/test/costs/cost-snapshot-repository.test.ts b/apps/api/test/costs/cost-snapshot-repository.test.ts index 7e9fe27b..21adb506 100644 --- a/apps/api/test/costs/cost-snapshot-repository.test.ts +++ b/apps/api/test/costs/cost-snapshot-repository.test.ts @@ -208,6 +208,10 @@ class AufzeichnendeIdempotenz implements IdempotencyStore { return this.optionen.bekannt ?? null; } + async readResultPayload(): Promise { + throw new Error("Dieser Kostentest liest keine Payload-Wiederholung."); + } + async remember( _tx: TenantQuery, _organisationId: string, @@ -219,6 +223,10 @@ class AufzeichnendeIdempotenz implements IdempotencyStore { this.rufe.push("remember"); this.gemerkt.push({ subjectId, fingerprint: requestFingerprint }); } + + async rememberWithResultPayload(): Promise { + throw new Error("Dieser Kostentest schreibt keine Payload-Wiederholung."); + } } function gespeicherteKopfzeile(ueberschreibung: Record = {}) { diff --git a/apps/api/test/costs/rate-batch-read.test.ts b/apps/api/test/costs/rate-batch-read.test.ts index b4a96219..1689a67a 100644 --- a/apps/api/test/costs/rate-batch-read.test.ts +++ b/apps/api/test/costs/rate-batch-read.test.ts @@ -137,9 +137,15 @@ const idempotenzWaechter: IdempotencyStore = { find: () => { throw new Error("Eine Lesung fragt keine Wiederholung ab."); }, + readResultPayload: () => { + throw new Error("Eine Lesung liest kein Wiederholungsergebnis."); + }, remember: () => { throw new Error("Eine Lesung merkt sich nichts."); }, + rememberWithResultPayload: () => { + throw new Error("Eine Lesung merkt sich kein Wiederholungsergebnis."); + }, }; function repositoryMit( diff --git a/apps/api/test/openapi-route-conformance.test.ts b/apps/api/test/openapi-route-conformance.test.ts index 2706418b..56d280d2 100644 --- a/apps/api/test/openapi-route-conformance.test.ts +++ b/apps/api/test/openapi-route-conformance.test.ts @@ -92,6 +92,11 @@ const NOT_YET_IMPLEMENTED: ReadonlyMap = new Map([ "Zeiterfassung. Es gibt keine time_entries-Tabelle — bewusst, siehe Migration 0010 Kopfkommentar (EYT-14).", ], ["POST /einsatz/zeiten/stopp", "Zeiterfassung. Gleiche Begruendung wie der Start."], + [ + "POST /planung/baustellentage/team", + "Tagesbesetzung ersetzen. Braucht zusaetzlich die Lock-Version-Fortschreibung " + + "und das atomare Ersetzen der Zuweisungen — EYT-147 M4.", + ], // `GET /kosten/planversionen` stand hier bis EYT-144. Die Route ist am // `CostsController` registriert; die `stale`-Zusicherung weiter unten hat den // Eintrag in genau dem Lauf rot gemeldet, in dem die Route entstand. diff --git a/apps/api/test/planning-gateway-parity.test.ts b/apps/api/test/planning-gateway-parity.test.ts index f49344c1..e4b73f89 100644 --- a/apps/api/test/planning-gateway-parity.test.ts +++ b/apps/api/test/planning-gateway-parity.test.ts @@ -159,6 +159,8 @@ async function httpGatewayHarness(): Promise { }); return Promise.resolve({ ok: true, replayed: false, assignment }); }, + planWorksiteDay: () => + Promise.reject(new Error("planWorksiteDay wird von der Vertragsparitaet nicht abgedeckt")), // Die geteilte Vertragssuite deckt `publishPlan` nicht ab (ihre sieben // Faelle betreffen Fenster und Anlegen). Ein stiller Erfolg hier waere // die schlechteste Attrappe: sie liesse einen kuenftigen Publish-Fall diff --git a/apps/api/test/planning-publish.http.test.ts b/apps/api/test/planning-publish.http.test.ts index 4755efc4..161b1048 100644 --- a/apps/api/test/planning-publish.http.test.ts +++ b/apps/api/test/planning-publish.http.test.ts @@ -80,6 +80,7 @@ async function boot(options: BootOptions = {}): Promise { ({ validateDraft: () => Promise.reject(new Error("in dieser Suite nicht benutzt")), createAssignment: () => Promise.reject(new Error("in dieser Suite nicht benutzt")), + planWorksiteDay: () => Promise.reject(new Error("in dieser Suite nicht benutzt")), publishPlan: (eingabe) => { options.gesehen?.push(eingabe); return Promise.resolve( diff --git a/apps/api/test/planning-window-single-snapshot.test.ts b/apps/api/test/planning-window-single-snapshot.test.ts new file mode 100644 index 00000000..882bb3da --- /dev/null +++ b/apps/api/test/planning-window-single-snapshot.test.ts @@ -0,0 +1,66 @@ +/** + * EYT-158 — der Planstand einer Version kommt aus EINEM Statement. + * + * `PgTenantQueryRunner` oeffnet die Transaktion mit einem nackten `begin`, also + * READ COMMITTED: jedes Statement sieht seinen eigenen Snapshot. Wuerden + * Zuweisungen und Baustellentage in zwei Statements gelesen, koennte ein + * dazwischen committeter `planWorksiteDay` einen Tag liefern, dessen + * Teammitglieder in `assignments` noch fehlen — und `PlanningWindowSchema` + * verwirft genau diese Antwort („Teammitglied verweist auf eine Zuweisung, + * die nicht unter assignments steht"): aus einem gueltigen Serverstand wuerde + * ein 500 und im Readback nach dem Speichern ein falscher Fehler. + * + * Statischer Waechter: gezaehlt werden die Statements, die `public.assignments` + * lesen. Gegenmutation: Zuweisungen und Tage wieder getrennt lesen → zwei + * Treffer, rot. Die fachliche Richtigkeit der Abfrage misst + * `planning-write.integration.test.ts` gegen echtes PostgreSQL. + */ +import { describe, expect, it } from "vitest"; + +import { PlanningWindowRepository } from "../src/modules/planning"; +import type { + QueryResult, + TenantQuery, + TenantQueryRunner, +} from "../src/platform/database/tenant-query-runner"; + +const ORG = "00000000-0000-4000-8000-0000000000a1"; +const USER = "00000000-0000-4000-8000-00000000aaa1"; +const VERSION = "aaaa3333-3333-4333-8333-333333333333"; + +function antwort(rows: TRow[]): QueryResult { + return { rows, rowCount: rows.length }; +} + +describe("EYT-158 — Planstand in EINEM Snapshot", () => { + it("liest Zuweisungen und Baustellentage einer Version mit genau einem Statement", async () => { + const statements: string[] = []; + const tx: TenantQuery = { + query: (sql: string): Promise> => { + statements.push(sql); + if (/from public\.organizations/.test(sql)) { + return Promise.resolve( + antwort([{ id: ORG, time_zone: "Europe/Berlin" }] as unknown as TRow[]), + ); + } + if (/from public\.plan_versions/.test(sql)) { + return Promise.resolve( + antwort([ + { id: VERSION, published_at: null, created_at: new Date("2028-01-01T00:00:00Z") }, + ] as unknown as TRow[]), + ); + } + return Promise.resolve(antwort([])); + }, + }; + const runner: TenantQueryRunner = { run: (_context, work) => work(tx) }; + + const ergebnis = await new PlanningWindowRepository(runner, USER).planningWindow("2028-W02"); + expect(ergebnis.ok).toBe(true); + + const planstand = statements.filter((sql) => /public\.assignments/.test(sql)); + expect(planstand, "Statements, die public.assignments lesen").toHaveLength(1); + expect(planstand[0]).toMatch(/public\.worksite_day_configurations/); + expect(planstand[0]).toMatch(/public\.worksite_days/); + }); +}); diff --git a/apps/api/test/planning-window.http.test.ts b/apps/api/test/planning-window.http.test.ts index 392e276c..f62943d1 100644 --- a/apps/api/test/planning-window.http.test.ts +++ b/apps/api/test/planning-window.http.test.ts @@ -103,6 +103,70 @@ afterEach(async () => { }); describe("GET /planung/fenster", () => { + it("EYT-158: bildet Baustellentag und drei Teammitglieder auf den bestehenden Vertrag ab", async () => { + const worksiteId = crypto.randomUUID(); + const assignments = Array.from({ length: 3 }, () => ({ + id: crypto.randomUUID(), + employeeId: crypto.randomUUID(), + worksiteId, + startsAtUtc: new Date("2028-01-10T07:00:00.000Z"), + endsAtUtc: new Date("2028-01-10T17:00:00.000Z"), + })); + const day = { + worksiteDayId: crypto.randomUUID(), + configurationId: crypto.randomUUID(), + worksiteId, + localDate: "2028-01-10", + lockVersion: 7, + team: assignments.map((a) => ({ + assignmentId: a.id, + employeeId: a.employeeId, + startsAtUtc: a.startsAtUtc, + endsAtUtc: a.endsAtUtc, + })), + }; + const server = ( + await boot({ + subject: SUBJECT, + result: { + ok: true, + window: { + weekKey: "2028-W02", + timeZone: "Europe/Berlin", + assignments, + worksiteDays: [day], + sourceVersion: { id: crypto.randomUUID(), state: "draft" }, + publishedVersionId: null, + resources: { + worksites: [{ id: worksiteId, label: "Allee", active: true }], + employees: assignments.map((a, index) => ({ + id: a.employeeId, + label: `Team ${index}`, + active: true, + })), + }, + }, + }, + }) + ).getHttpServer(); + const response = await request(server).get(url).query({ weekKey: "2028-W02" }).expect(200); + // RED-Phase vor EYT-158: das HTTP-Mapping liess worksiteDays weg. + expect(response.body.worksiteDays).toEqual([ + { + ...day, + team: day.team.map((member) => ({ + assignmentId: member.assignmentId, + employeeId: member.employeeId, + interval: { + startUtc: member.startsAtUtc.toISOString(), + endUtc: member.endsAtUtc.toISOString(), + }, + })), + }, + ]); + expect(PlanningWindowSchema.safeParse(response.body).success).toBe(true); + }); + it("antwortet ohne verifiziertes Subjekt mit 401", async () => { const server = (await boot({ subject: null })).getHttpServer(); await request(server).get(url).query({ weekKey: "2026-W32" }).expect(401); diff --git a/apps/api/test/planning-worksite-day.http.test.ts b/apps/api/test/planning-worksite-day.http.test.ts new file mode 100644 index 00000000..249f53b4 --- /dev/null +++ b/apps/api/test/planning-worksite-day.http.test.ts @@ -0,0 +1,127 @@ +/** + * HTTP-Schnittstelle fuer den ersten WorksiteDay-Write (EYT-152). + * + * Dieser erste RED-Fall beweist bewusst nur den vertikalen Eintrittspunkt: + * eine verifizierte Planerin mit `planning.write` kann einen gueltigen, + * nicht-leeren Baustellentag ueber den vertraglich publizierten Pfad anlegen. + * Die Datenbank-Invarianten folgen in der Integrationssuite, sobald der Port + * und sein PostgreSQL-Adapter existieren. + */ +import type { INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AppModule } from "../src/app.module"; +import { API_BASE_PATH } from "../src/common/api-base-path"; +import { mitPlanungsIdentitaet } from "./planning-identity.helper"; +import { DATABASE_PING, type DatabasePing } from "../src/health/readiness"; +import { + PLANNING_WRITES_FACTORY, + type PlanningWrites, + type PlanningWritesFactory, +} from "../src/modules/planning"; + +const SUBJECT = "00000000-0000-4000-8000-00000000aaa1"; +const url = `/${API_BASE_PATH}/planung/baustellentage`; +const idempotencyKey = "11111111-1111-4111-8111-111111111111"; + +const gueltigerBaustellentag = { + weekKey: "2026-W32", + worksiteId: "00000000-0000-4000-8000-0000005010a1", + localDate: "2026-08-03", + team: [ + { + employeeId: "00000000-0000-4000-8000-0000004010a1", + interval: { + startUtc: "2026-08-03T06:00:00.000Z", + endUtc: "2026-08-03T14:00:00.000Z", + }, + }, + ], +}; + +let app: INestApplication | null = null; + +afterEach(async () => { + await app?.close(); + app = null; +}); + +async function boot( + options: { subject?: string | null; permissions?: readonly string[] } = {}, +): Promise { + const writes: PlanningWritesFactory = () => + ({ + validateDraft: () => Promise.reject(new Error("nicht Teil dieses HTTP-RED-Falls")), + createAssignment: () => Promise.reject(new Error("nicht Teil dieses HTTP-RED-Falls")), + planWorksiteDay: (input) => + Promise.resolve({ + ok: true, + replayed: false, + worksiteDay: { + worksiteDayId: "00000000-0000-4000-8000-0000007010a1", + configurationId: "00000000-0000-4000-8000-0000008010a1", + worksiteId: input.worksiteId, + localDate: input.localDate, + lockVersion: 0, + team: input.team.map((member, index) => ({ + assignmentId: `00000000-0000-4000-8000-00000090${String(index).padStart(2, "0")}a1`, + employeeId: member.employeeId, + startsAtUtc: member.startsAtUtc, + endsAtUtc: member.endsAtUtc, + })), + }, + }), + publishPlan: () => Promise.reject(new Error("nicht Teil dieses HTTP-RED-Falls")), + }) satisfies PlanningWrites; + + const moduleRef = await mitPlanungsIdentitaet( + Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DATABASE_PING) + .useValue({ ping: (): Promise => Promise.resolve(true) } satisfies DatabasePing) + .overrideProvider(PLANNING_WRITES_FACTORY) + .useValue(writes), + { + subject: options.subject === undefined ? SUBJECT : options.subject, + permissions: options.permissions ?? ["planning.write"], + }, + ).compile(); + + const built = moduleRef.createNestApplication(); + built.setGlobalPrefix(API_BASE_PATH, { exclude: ["health", "ready"] }); + await built.init(); + app = built; + return built; +} + +describe("POST /planung/baustellentage", () => { + it("legt einen gueltigen Baustellentag ueber den vertraglichen HTTP-Pfad an", async () => { + const server = (await boot()).getHttpServer(); + + const response = await request(server) + .post(url) + .set("Idempotency-Key", idempotencyKey) + .send(gueltigerBaustellentag) + .expect(201); + + expect(response.body).toMatchObject({ + worksiteId: gueltigerBaustellentag.worksiteId, + localDate: gueltigerBaustellentag.localDate, + }); + }); + + it("weist fehlende Identität vor Vertragsprüfung mit 401 zurück", async () => { + const server = (await boot({ subject: null })).getHttpServer(); + await request(server).post(url).send({}).expect(401); + }); + + it("weist fehlendes planning.write vor jeder Repository-Mutation mit 403 zurück", async () => { + const server = (await boot({ permissions: [] })).getHttpServer(); + await request(server) + .post(url) + .set("Idempotency-Key", idempotencyKey) + .send(gueltigerBaustellentag) + .expect(403); + }); +}); diff --git a/apps/api/test/planning-write.integration.test.ts b/apps/api/test/planning-write.integration.test.ts index 387712a3..986a3a4b 100644 --- a/apps/api/test/planning-write.integration.test.ts +++ b/apps/api/test/planning-write.integration.test.ts @@ -31,8 +31,10 @@ */ import { createTimeZone, + dayAfter, isoWeekOfLocalDate, localBusinessDate, + planningWeekDateRange, planningWeekKey, } from "@easytree/domain"; import { Client } from "pg"; @@ -46,7 +48,7 @@ import { probeDatabase, USER_A, } from "./tenant-context.helper"; -import { PlanningWriteRepository } from "../src/modules/planning"; +import { PlanningWindowRepository, PlanningWriteRepository } from "../src/modules/planning"; import { PgIdempotencyStore } from "../src/platform/idempotency/pg-idempotency-store"; import type { TenantQuery, TenantQueryRunner } from "../src/platform/database/tenant-query-runner"; @@ -142,6 +144,7 @@ const WOCHE_RECHT_VERSION = "2026-W49"; const WOCHE_RECHT_ZUWEISUNG = "2026-W50"; /** Eigene Woche fuer den Definer-Nachweis mit entzogenem planning.publish (EYT-136). */ const WOCHE_DEFINER_OHNE_PUBLISH = "2026-W51"; +const WOCHE_WORKSITE_DAY = "2026-W52"; const START = new Date("2026-11-03T07:00:00Z"); const ENDE = new Date("2026-11-03T15:00:00Z"); @@ -196,6 +199,46 @@ function runnerAuf(client: Client): TenantQueryRunner { }; } +/** + * Synchronisiert zwei ECHTE Transaktionen direkt vor ihrem konkurrierenden + * `app.lock_week_draft`-Aufruf. Die Produktionsnaht bleibt der bestehende Runner; + * nur der Test beobachtet seine SQL-Grenze. Ein bloßes `Promise.all` könnte + * den zweiten Aufruf erst nach dem Commit des ersten starten lassen und würde + * die Konkurrenz dann nur behaupten. + */ +function synchronisierteWorksiteDayRunner( + client: Client, + barrier: { arrive(): Promise }, +): TenantQueryRunner { + const base = runnerAuf(client); + return { + run: (kontext, work) => + base.run(kontext, (tx) => + work({ + query: async (sql: string, params?: readonly unknown[]) => { + if (sql.includes("select app.lock_week_draft")) await barrier.arrive(); + return tx.query(sql, params); + }, + }), + ), + }; +} + +function barrierFuerZwei(): { arrive(): Promise } { + let arrived = 0; + let release: (() => void) | null = null; + const released = new Promise((resolve) => { + release = resolve; + }); + return { + async arrive(): Promise { + arrived += 1; + if (arrived === 2) release?.(); + await released; + }, + }; +} + /** * Die ECHTE Wochenregel, nicht ein Stub. * @@ -415,6 +458,10 @@ async function raeumeWoche(woche: string = WOCHE): Promise { ORG_ALPHA, "planning.assignment_created", ]); + await client.query("delete from public.audit_events where org_id = $1 and event_type = $2", [ + ORG_ALPHA, + "planning.worksite_day_planned", + ]); await client.query( `insert into public.employees (id, org_id, user_id, display_name, active) values ($1, $2, null, 'Alpha Zweite (Testfixture EYT-92)', true) @@ -749,6 +796,7 @@ afterAll(async () => { WOCHE_RECHT_VERSION, WOCHE_RECHT_ZUWEISUNG, WOCHE_DEFINER_OHNE_PUBLISH, + WOCHE_WORKSITE_DAY, ]) { await raeumeWoche(woche); } @@ -776,6 +824,600 @@ afterAll(async () => { }); describe("Schreibpfad gegen echtes PostgreSQL (EYT-92)", () => { + dbIt( + "EYT-158: PlanningWindow liest drei reale Teammitglieder mit identischen Write-IDs zurück", + async () => { + const weekKey = "2028-W02"; + const employee3 = "00000000-0000-4000-8000-000000e15803"; + await raeumeWoche(weekKey); + // Idempotent: ein abgebrochener Lauf (Timeout, Cancel) laesst die Zeile + // stehen, und ein nackter INSERT machte jeden spaeteren Lauf mit 23505 rot. + await verwalte( + `insert into public.employees (id, org_id, display_name, active) + values ($1, $2, 'Clara Baum (Test EYT-158)', true) + on conflict (id) do update set active = true, display_name = excluded.display_name`, + [employee3, ORG_ALPHA], + ); + const client = await neueVerbindung(); + const runner = runnerAuf(client); + try { + const write = await new PlanningWriteRepository( + runner, + USER_A, + wochenschluessel, + idempotenzSpeicher, + ).planWorksiteDay({ + weekKey, + worksiteId: WORKSITE_ALPHA, + localDate: "2028-01-10", + team: [EMPLOYEE_ALPHA, EMPLOYEE_ALPHA_2, employee3].map((employeeId) => ({ + employeeId, + startsAtUtc: new Date("2028-01-10T07:00:00.000Z"), + endsAtUtc: new Date("2028-01-10T17:00:00.000Z"), + })), + idempotencyKey: crypto.randomUUID(), + }); + if (!write.ok) throw new Error("Drei-Personen-Write abgelehnt."); + const read = await new PlanningWindowRepository(runner, USER_A).planningWindow(weekKey); + if (!read.ok) throw new Error("Readback abgelehnt."); + // RED-Phase vor EYT-158: die WorksiteDay-Projektion fehlte vollständig. + expect(read.window).toMatchObject({ worksiteDays: [write.worksiteDay] }); + expect(read.window.assignments.map((a) => a.id).sort()).toEqual( + write.worksiteDay.team.map((a) => a.assignmentId).sort(), + ); + const foreign = await new PlanningWindowRepository(runner, USER_B).planningWindow(weekKey); + if (!foreign.ok) throw new Error("Eigener Beta-Read abgelehnt."); + expect(foreign.window).toMatchObject({ worksiteDays: [], assignments: [] }); + } finally { + await raeumeWoche(weekKey); + await verwalte("delete from public.employees where id = $1", [employee3]); + } + }, + ); + + dbIt( + "EYT-158: Folgedraft übernimmt Tagesrevision und Legacy-Einplanung ohne Historienmutation", + async () => { + // Veröffentlichte Historie bleibt stehen. Jeder Lauf reserviert deshalb + // eine bisher unbenutzte Testwoche; kein Löschen/Entsperren der Historie. + const [frei] = await beobachte<{ jahr: number }>( + `select jahr from generate_series(2100, 9998) jahr + where not exists (select 1 from public.plan_versions where week_key = jahr::text || '-W01') + order by jahr limit 1`, + ); + if (frei === undefined) throw new Error("Keine freie Testwoche."); + const weekKey = `${frei.jahr}-W01`; + const montag = planningWeekDateRange({ isoYear: frei.jahr, isoWeek: 1 }).monday; + const datum = (tag: typeof montag) => + `${tag.year}-${String(tag.month).padStart(2, "0")}-${String(tag.day).padStart(2, "0")}`; + const ersterTag = datum(montag); + const zweiterTag = datum(dayAfter(montag)); + const client = await neueVerbindung(); + const repo = new PlanningWriteRepository( + runnerAuf(client), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ); + const command = (tag: string) => ({ + weekKey, + worksiteId: WORKSITE_ALPHA, + localDate: tag, + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date(`${tag}T07:00:00.000Z`), + endsAtUtc: new Date(`${tag}T11:00:00.000Z`), + }, + ], + idempotencyKey: crypto.randomUUID(), + }); + const original = await repo.planWorksiteDay(command(ersterTag)); + if (!original.ok) throw new Error("Baseline-Command abgelehnt."); + const legacy = await repo.createAssignment({ + weekKey, + worksiteId: WORKSITE_ALPHA, + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date(`${ersterTag}T12:00:00.000Z`), + endsAtUtc: new Date(`${ersterTag}T13:00:00.000Z`), + idempotencyKey: crypto.randomUUID(), + }); + if (!legacy.ok) throw new Error("Legacy-Baseline abgelehnt."); + await verwalte( + "update public.worksite_day_configurations set lock_version = 7 where id = $1", + [original.worksiteDay.configurationId], + ); + const published = await repo.publishPlan({ + weekKey, + expectedVersionId: legacy.assignment.planVersionId, + idempotencyKey: crypto.randomUUID(), + }); + expect(published.ok).toBe(true); + const snapshot = () => + beobachte( + `select to_jsonb(p) as version, + (select jsonb_agg(to_jsonb(c) order by c.id) from public.worksite_day_configurations c where c.plan_version_id = p.id) as configurations, + (select jsonb_agg(to_jsonb(a) order by a.id) from public.assignments a where a.plan_version_id = p.id) as assignments + from public.plan_versions p where p.id = $1`, + [legacy.assignment.planVersionId], + ); + const vorher = await snapshot(); + try { + const drafts = () => + beobachte( + "select id from public.plan_versions where week_key = $1 and org_id = $2 and published_at is null", + [weekKey, ORG_ALPHA], + ); + // Ablehnung beim ersten Write nach Publish darf auch die inzwischen + // kopierten Konfigurationen und den Folgedraft nicht zurücklassen. + expect(await repo.planWorksiteDay(command(ersterTag))).toEqual({ + ok: false, + problem: { kind: "DUPLICATE_WORKSITE_DAY" }, + }); + expect(await drafts()).toEqual([]); + expect(await snapshot()).toEqual(vorher); + + const base = runnerAuf(client); + let copyInserted = false; + const failing: TenantQueryRunner = { + run: (context, work) => + base.run(context, (tx) => + work({ + query: async (sql: string, params?: readonly unknown[]) => { + const result = await tx.query(sql, params); + if (sql.includes("insert into public.assignments")) { + copyInserted = true; + throw new Error("EYT-158: Fehler nach Baseline-Insert"); + } + return result; + }, + }), + ), + }; + const failedInput = command(zweiterTag); + await expect( + new PlanningWriteRepository( + failing, + USER_A, + wochenschluessel, + idempotenzSpeicher, + ).planWorksiteDay(failedInput), + ).rejects.toThrow("EYT-158: Fehler nach Baseline-Insert"); + expect(copyInserted).toBe(true); + expect(await drafts()).toEqual([]); + expect(await snapshot()).toEqual(vorher); + expect( + await beobachte( + "select id from public.worksite_days where org_id = $1 and worksite_id = $2 and local_date = $3::date", + [ORG_ALPHA, WORKSITE_ALPHA, zweiterTag], + ), + ).toEqual([]); + + // Zwei echte Transaktionen konkurrieren am bestehenden Draft-Helfer. + // Auch beim Folgedraft genau ein Gewinner, keine SQL-/500-Fehler. + const other = await neueVerbindung(); + await client.query("set statement_timeout = '5s'"); + await other.query("set statement_timeout = '5s'"); + const barrier = barrierFuerZwei(); + const inputs = [failedInput, command(zweiterTag)]; + const results = await Promise.allSettled( + [client, other].map((connection, index) => { + const input = inputs[index]; + if (input === undefined) throw new Error("Testinput fehlt."); + return new PlanningWriteRepository( + synchronisierteWorksiteDayRunner(connection, barrier), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ).planWorksiteDay(input); + }), + ); + const outcomes = results.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + expect(outcomes.filter((result) => result.ok)).toHaveLength(1); + expect(outcomes.filter((result) => !result.ok)).toEqual([ + { ok: false, problem: { kind: "DUPLICATE_WORKSITE_DAY" } }, + ]); + const winner = outcomes.findIndex((result) => result.ok); + const input = inputs[winner]; + const created = outcomes[winner]; + if (input === undefined || created === undefined) throw new Error("Kein Gewinner."); + expect(created.ok).toBe(true); + if (!created.ok) throw new Error("Folgedraft-Command abgelehnt."); + const configs = await beobachte<{ + id: string; + worksite_day_id: string; + lock_version: number; + }>( + `select c.id, c.worksite_day_id, c.lock_version from public.worksite_day_configurations c + join public.plan_versions p on p.id = c.plan_version_id + where p.week_key = $1 and p.org_id = $2 and p.published_at is null`, + [weekKey, ORG_ALPHA], + ); + // RED-Phase vor EYT-158: nur die neu angelegte Konfiguration existierte. + expect(configs, "neuer Tag UND übernommener Vorgängertag").toHaveLength(2); + const copy = configs.find((c) => c.worksite_day_id === original.worksiteDay.worksiteDayId); + expect(copy?.id).toBeDefined(); + expect(copy?.id).not.toBe(original.worksiteDay.configurationId); + expect(copy?.lock_version).toBe(7); + const assignments = await beobachte<{ + id: string; + worksite_day_configuration_id: string | null; + employee_id: string; + starts_at_utc: Date; + ends_at_utc: Date; + published_at: Date | null; + }>( + `select a.id, a.worksite_day_configuration_id, a.employee_id, a.starts_at_utc, a.ends_at_utc, a.published_at + from public.assignments a join public.plan_versions p on p.id = a.plan_version_id + where p.week_key = $1 and p.org_id = $2 and p.published_at is null`, + [weekKey, ORG_ALPHA], + ); + expect(assignments).toHaveLength(3); + expect(assignments.find((a) => a.worksite_day_configuration_id === copy?.id)).toMatchObject( + { + employee_id: EMPLOYEE_ALPHA, + starts_at_utc: new Date(`${ersterTag}T07:00:00.000Z`), + ends_at_utc: new Date(`${ersterTag}T11:00:00.000Z`), + published_at: null, + }, + ); + expect(assignments.filter((a) => a.worksite_day_configuration_id === null)).toHaveLength(1); + expect(assignments.map((a) => a.id)).not.toContain( + original.worksiteDay.team[0]?.assignmentId, + ); + expect(assignments.map((a) => a.id)).not.toContain(legacy.assignment.id); + expect(await repo.planWorksiteDay(input)).toEqual({ ...created, replayed: true }); + expect(await repo.planWorksiteDay(command(ersterTag))).toEqual({ + ok: false, + problem: { kind: "DUPLICATE_WORKSITE_DAY" }, + }); + expect(await snapshot()).toEqual(vorher); + } finally { + await raeumeWoche(weekKey); + } + }, + 30_000, + ); + + dbIt( + "EYT-158: auch ein Folgedraft aus dem Einzel-Einsatzpfad uebernimmt die Tagesrevision — der Baustellentag bleibt EINE Karte", + async () => { + // Zwei Pfade legen den Folgedraft ueber einer veroeffentlichten Woche an: + // planWorksiteDay und createAssignment. Welcher zuerst kommt, entscheidet + // ein Klick — die Uebernahme der Tagesrevision darf davon nicht abhaengen, + // sonst verschwindet die Karte und ihr Team faellt auf Legacy-Zeilen zurueck. + const [frei] = await beobachte<{ jahr: number }>( + `select jahr from generate_series(2100, 9998) jahr + where not exists (select 1 from public.plan_versions where week_key = jahr::text || '-W01') + order by jahr limit 1`, + ); + if (frei === undefined) throw new Error("Keine freie Testwoche."); + const weekKey = `${frei.jahr}-W01`; + const montag = planningWeekDateRange({ isoYear: frei.jahr, isoWeek: 1 }).monday; + const datum = (tag: typeof montag) => + `${tag.year}-${String(tag.month).padStart(2, "0")}-${String(tag.day).padStart(2, "0")}`; + const ersterTag = datum(montag); + const zweiterTag = datum(dayAfter(montag)); + const client = await neueVerbindung(); + const runner = runnerAuf(client); + const repo = new PlanningWriteRepository( + runner, + USER_A, + wochenschluessel, + idempotenzSpeicher, + ); + const lesen = new PlanningWindowRepository(runner, USER_A); + + const original = await repo.planWorksiteDay({ + weekKey, + worksiteId: WORKSITE_ALPHA, + localDate: ersterTag, + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date(`${ersterTag}T07:00:00.000Z`), + endsAtUtc: new Date(`${ersterTag}T11:00:00.000Z`), + }, + ], + idempotencyKey: crypto.randomUUID(), + }); + if (!original.ok) throw new Error("Baseline-Command abgelehnt."); + await verwalte( + "update public.worksite_day_configurations set lock_version = 5 where id = $1", + [original.worksiteDay.configurationId], + ); + const [entwurf] = await beobachte<{ id: string }>( + "select id from public.plan_versions where week_key = $1 and org_id = $2 and published_at is null", + [weekKey, ORG_ALPHA], + ); + if (entwurf === undefined) throw new Error("Kein Entwurf zum Veroeffentlichen."); + const published = await repo.publishPlan({ + weekKey, + expectedVersionId: entwurf.id, + idempotencyKey: crypto.randomUUID(), + }); + expect(published.ok).toBe(true); + + try { + // Der ERSTE Schreibvorgang nach dem Publish kommt ueber den + // Einzel-Einsatzpfad — er legt den Folgedraft an. + const legacy = await repo.createAssignment({ + weekKey, + worksiteId: WORKSITE_ALPHA, + employeeId: EMPLOYEE_ALPHA_2, + startsAtUtc: new Date(`${zweiterTag}T07:00:00.000Z`), + endsAtUtc: new Date(`${zweiterTag}T09:00:00.000Z`), + idempotencyKey: crypto.randomUUID(), + }); + if (!legacy.ok) throw new Error("Einzel-Einsatz nach Publish abgelehnt."); + + const gelesen = await lesen.planningWindow(weekKey); + if (!gelesen.ok) throw new Error("Readback abgelehnt."); + expect(gelesen.window.sourceVersion).toEqual({ + id: legacy.assignment.planVersionId, + state: "draft", + }); + // RED-Phase: der Folgedraft aus createAssignment trug keine Tageskonfiguration. + expect(gelesen.window.worksiteDays).toHaveLength(1); + const kopie = gelesen.window.worksiteDays?.[0]; + expect(kopie).toMatchObject({ + worksiteDayId: original.worksiteDay.worksiteDayId, + worksiteId: WORKSITE_ALPHA, + localDate: ersterTag, + lockVersion: 5, + }); + expect(kopie?.configurationId).not.toBe(original.worksiteDay.configurationId); + expect(kopie?.team.map((m) => m.employeeId)).toEqual([EMPLOYEE_ALPHA]); + expect(kopie?.team[0]?.assignmentId).not.toBe(original.worksiteDay.team[0]?.assignmentId); + expect(kopie?.team[0]).toMatchObject({ + startsAtUtc: new Date(`${ersterTag}T07:00:00.000Z`), + endsAtUtc: new Date(`${ersterTag}T11:00:00.000Z`), + }); + // Die Kopie und die neue Legacy-Einplanung — nichts doppelt. + expect(gelesen.window.assignments).toHaveLength(2); + expect(gelesen.window.assignments.map((a) => a.id)).toContain(legacy.assignment.id); + + // Im Folgedraft ist derselbe Tag ein Duplikat, keine zweite Karte. + expect( + await repo.planWorksiteDay({ + weekKey, + worksiteId: WORKSITE_ALPHA, + localDate: ersterTag, + team: [ + { + employeeId: EMPLOYEE_ALPHA_2, + startsAtUtc: new Date(`${ersterTag}T12:00:00.000Z`), + endsAtUtc: new Date(`${ersterTag}T13:00:00.000Z`), + }, + ], + idempotencyKey: crypto.randomUUID(), + }), + ).toEqual({ ok: false, problem: { kind: "DUPLICATE_WORKSITE_DAY" } }); + } finally { + await raeumeWoche(weekKey); + } + }, + 30_000, + ); + + dbIt( + "legt einen Baustellentag, seine Revision und die Personzuordnung atomar an (EYT-152 RED)", + async () => { + const weekKey = "2026-W52"; + await raeumeWoche(weekKey); + const client = await neueVerbindung(); + const repo = new PlanningWriteRepository( + runnerAuf(client), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ); + + const result = await repo.planWorksiteDay({ + weekKey, + worksiteId: WORKSITE_ALPHA, + localDate: "2026-12-21", + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date("2026-12-21T07:00:00.000Z"), + endsAtUtc: new Date("2026-12-21T15:00:00.000Z"), + }, + ], + idempotencyKey: "00000000-0000-4000-8000-000000e15201", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("Der Baustellentag wurde unerwartet abgelehnt."); + expect(result.worksiteDay.team).toHaveLength(1); + + const persisted = await beobachte<{ + day_count: string; + configuration_count: string; + assignment_count: string; + }>( + `select + (select count(*) from public.worksite_days where org_id = $1 and worksite_id = $2 and local_date = $3)::text as day_count, + (select count(*) from public.worksite_day_configurations c join public.plan_versions p on p.id = c.plan_version_id where p.org_id = $1 and p.week_key = $4)::text as configuration_count, + (select count(*) from public.assignments a join public.plan_versions p on p.id = a.plan_version_id where p.org_id = $1 and p.week_key = $4 and a.worksite_day_configuration_id is not null)::text as assignment_count`, + [ORG_ALPHA, WORKSITE_ALPHA, "2026-12-21", weekKey], + ); + expect(persisted[0]).toEqual({ + day_count: "1", + configuration_count: "1", + assignment_count: "1", + }); + }, + 30_000, + ); + + dbIt( + "speichert mehrere Teammitglieder atomar und replayt exakt den ersten Baustellentag", + async () => { + const client = await neueVerbindung(); + await raeumeWoche(WOCHE_WORKSITE_DAY); + const repo = new PlanningWriteRepository( + runnerAuf(client), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ); + const input = { + weekKey: WOCHE_WORKSITE_DAY, + worksiteId: WORKSITE_ALPHA, + localDate: "2026-12-22", + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date("2026-12-22T07:00:00.000Z"), + endsAtUtc: new Date("2026-12-22T15:00:00.000Z"), + }, + { + employeeId: EMPLOYEE_ALPHA_2, + startsAtUtc: new Date("2026-12-22T07:00:00.000Z"), + endsAtUtc: new Date("2026-12-22T15:00:00.000Z"), + }, + ], + idempotencyKey: "00000000-0000-4000-8000-000000e15202", + }; + + const first = await repo.planWorksiteDay(input); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.replayed).toBe(false); + expect(first.worksiteDay.team).toHaveLength(2); + + const replay = await repo.planWorksiteDay(input); + expect(replay).toEqual({ ok: true, replayed: true, worksiteDay: first.worksiteDay }); + + const stored = await beobachte<{ assignments: string; audits: string; records: string }>( + `select + (select count(*) from public.assignments a join public.plan_versions p on p.id = a.plan_version_id where p.week_key = $1 and a.worksite_day_configuration_id = $2)::text as assignments, + (select count(*) from public.audit_events where org_id = $3 and event_type = 'planning.worksite_day_planned')::text as audits, + (select count(*) from public.idempotency_records where org_id = $3 and operation = 'planning.plan_worksite_day')::text as records`, + [WOCHE_WORKSITE_DAY, first.worksiteDay.configurationId, ORG_ALPHA], + ); + expect(stored[0]).toEqual({ assignments: "2", audits: "1", records: "1" }); + + const reused = await repo.planWorksiteDay({ ...input, localDate: "2026-12-23" }); + expect(reused).toEqual({ ok: false, problem: { kind: "IDEMPOTENCY_KEY_REUSED" } }); + }, + ); + + dbIt( + "rollt Identitaet, Konfiguration und Team bei einer Überschneidung vollständig zurück", + async () => { + const client = await neueVerbindung(); + await raeumeWoche(WOCHE_WORKSITE_DAY); + const repo = new PlanningWriteRepository( + runnerAuf(client), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ); + const day = "2026-12-23"; + const result = await repo.planWorksiteDay({ + weekKey: WOCHE_WORKSITE_DAY, + worksiteId: WORKSITE_ALPHA, + localDate: day, + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date("2026-12-23T07:00:00.000Z"), + endsAtUtc: new Date("2026-12-23T15:00:00.000Z"), + }, + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date("2026-12-23T14:00:00.000Z"), + endsAtUtc: new Date("2026-12-23T18:00:00.000Z"), + }, + ], + idempotencyKey: "00000000-0000-4000-8000-000000e15203", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.problem.kind).toBe("OVERLAPPING_ASSIGNMENT"); + const residue = await beobachte<{ + versions: string; + configs: string; + assignments: string; + records: string; + }>( + `select + (select count(*) from public.plan_versions where week_key = $1 and published_at is null)::text as versions, + (select count(*) from public.worksite_day_configurations c join public.plan_versions p on p.id = c.plan_version_id where p.week_key = $1)::text as configs, + (select count(*) from public.assignments a join public.plan_versions p on p.id = a.plan_version_id where p.week_key = $1)::text as assignments, + (select count(*) from public.idempotency_records where org_id = $2 and operation = 'planning.plan_worksite_day')::text as records`, + [WOCHE_WORKSITE_DAY, ORG_ALPHA], + ); + expect(residue[0]).toEqual({ versions: "0", configs: "0", assignments: "0", records: "0" }); + }, + ); + + dbIt( + "synchron konkurrierende Baustellentage liefern genau einen Gewinner ohne SQL-Fehler oder Teilzustand", + async () => { + const a = await neueVerbindung(); + const b = await neueVerbindung(); + await raeumeWoche(WOCHE_WORKSITE_DAY); + const barrier = barrierFuerZwei(); + const input = { + weekKey: WOCHE_WORKSITE_DAY, + worksiteId: WORKSITE_ALPHA, + localDate: "2026-12-24", + team: [ + { + employeeId: EMPLOYEE_ALPHA, + startsAtUtc: new Date("2026-12-24T07:00:00.000Z"), + endsAtUtc: new Date("2026-12-24T15:00:00.000Z"), + }, + ], + }; + const ergebnisse = await Promise.allSettled([ + new PlanningWriteRepository( + synchronisierteWorksiteDayRunner(a, barrier), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ).planWorksiteDay({ ...input, idempotencyKey: "00000000-0000-4000-8000-000000e15204" }), + new PlanningWriteRepository( + synchronisierteWorksiteDayRunner(b, barrier), + USER_A, + wochenschluessel, + idempotenzSpeicher, + ).planWorksiteDay({ ...input, idempotencyKey: "00000000-0000-4000-8000-000000e15205" }), + ]); + expect(ergebnisse.every((result) => result.status === "fulfilled")).toBe(true); + const antworten = ergebnisse.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + expect(antworten.filter((result) => result.ok)).toHaveLength(1); + expect( + antworten.filter( + (result) => !result.ok && result.problem.kind === "DUPLICATE_WORKSITE_DAY", + ), + ).toHaveLength(1); + + const bestand = await beobachte<{ + days: string; + configurations: string; + assignments: string; + }>( + `select + (select count(*) from public.worksite_days where org_id = $1 and worksite_id = $2 and local_date = $3::date)::text as days, + (select count(*) from public.worksite_day_configurations c join public.plan_versions p on p.id = c.plan_version_id where p.week_key = $4)::text as configurations, + (select count(*) from public.assignments a join public.plan_versions p on p.id = a.plan_version_id where p.week_key = $4)::text as assignments`, + [ORG_ALPHA, WORKSITE_ALPHA, input.localDate, WOCHE_WORKSITE_DAY], + ); + expect(bestand[0]).toEqual({ days: "1", configurations: "1", assignments: "1" }); + }, + 30_000, + ); + dbIt( "derselbe Idempotenzschluessel liefert dieselbe Id und schreibt kein zweites Mal", async () => { diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 15c20dd0..b3cd2b72 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,347 @@ 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); +} + +/* EYT-158: die Karte ist der Baustellentag; das Einsatzteam steht untergeordnet + darunter. Datum als stille Zweitangabe — der Tag ist bereits die Spalte. */ +.einsatzkarte__datum { + font-size: 0.75rem; + color: var(--eyt-text-secondary); + font-variant-numeric: tabular-nums; +} + +.einsatzkarte__teamtitel { + margin-top: 0.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--eyt-text-secondary); +} + +.einsatzkarte__team { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.125rem; + font-size: 0.8125rem; +} + +/* Legacy-Einplanungen ohne Baustellentag (EYT-158): sichtbar, aber untergeordnet — + keine Karte, keine erfundene Tagesidentitaet. */ +.wochenraster__legacy > h4 { + margin: 0.375rem 0 0.125rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--eyt-text-secondary); +} + +.legacy-einplanung { + display: grid; + gap: 0.125rem; + padding: 0.25rem 0.625rem; + border-inline-start: 2px solid var(--eyt-border-default); + min-width: 0; + overflow-wrap: anywhere; +} + +/* 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..58b2939f 100644 --- a/apps/web/components/planning-assignment-form.tsx +++ b/apps/web/components/planning-assignment-form.tsx @@ -112,7 +112,10 @@ export type IntervallErgebnis = * Exportiert, weil dies die einzige Stelle ist, an der Wanduhrzeit zu einem * Instant wird — sie gehoert einzeln pruefbar, nicht nur ueber die Oberflaeche. */ -export function zuIntervall(eingabe: EntwurfEingabe, zone: string): IntervallErgebnis { +export function zuIntervall>( + eingabe: T, + zone: string, +): IntervallErgebnis { const tag = kalendertag(eingabe.datum); if (tag === null) return { ok: false, fehler: "DATUM_UNGUELTIG" }; const von = uhrzeit(eingabe.beginn); @@ -250,69 +253,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)} + /> +
+
+ ) : ( + Baustellentag anlegen + )} + + + {fenster.assignments.length === 0 && (fenster.worksiteDays?.length ?? 0) === 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 && + !fenster.worksiteDays?.some((day) => day.localDate === tag.tagKey) ? ( + // Nur Zierde fuer Sehende: die Abwesenheit einer Liste sagt + // dem Screenreader dasselbe, ein zweiter Text waere Rauschen. + + ) : ( + day.localDate === tag.tagKey) ?? []} + assignments={tag.einsaetze} + fenster={fenster} + /> + )} +
+ ))} + {raster.ausserhalb.length > 0 || + fenster.worksiteDays?.some( + (day) => !raster.tage.some((tag) => tag.tagKey === day.localDate), + ) ? ( + // 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.tage.some((tag) => tag.tagKey === day.localDate), + ) ?? [] + } + assignments={raster.ausserhalb} + fenster={fenster} + /> +
+ ) : 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.worksiteDays?.length ?? 0) > 0 ? ( +
+ +
+ ) : 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/components/planning-worksite-day-form.tsx b/apps/web/components/planning-worksite-day-form.tsx new file mode 100644 index 00000000..04febb9c --- /dev/null +++ b/apps/web/components/planning-worksite-day-form.tsx @@ -0,0 +1,212 @@ +"use client"; + +import type { GatewayFailure, PlanningWindow, PlanWorksiteDayCommand } from "@easytree/contracts"; +import { Button } from "@easytree/ui"; +import { useId, useState } from "react"; +import { UMRECHNUNG_TEXT, zuIntervall } from "./planning-assignment-form"; + +export type WorksiteDayInput = Omit; +const INITIAL = { + worksiteId: "", + datum: "", + beginn: "08:00", + ende: "18:00", + employeeIds: [] as string[], +}; +const ERRORS: Record = { + UNAUTHENTICATED: "Nicht angemeldet. Bitte erneut anmelden.", + FORBIDDEN: "Keine Berechtigung für diese Organisation.", + REJECTED: "Der Baustellentag wurde abgelehnt. Bitte die Eingabe prüfen.", + STALE_VERSION: "Der Planstand hat sich geändert. Bitte die Woche neu laden.", + UNAVAILABLE: "Der Server ist nicht erreichbar. Das Speichern ist nicht bestätigt.", + CONTRACT_VIOLATION: "Die Serverantwort ist ungültig. Das Speichern ist nicht bestätigt.", +}; + +/** Nur Eingabezustand. Erfolg und sichtbare Planung stammen aus dem Readback. */ +export function WorksiteDayForm({ + window: fenster, + onSubmit, +}: { + readonly window: PlanningWindow; + readonly onSubmit: ( + input: WorksiteDayInput, + ) => Promise<{ ok: boolean; failure?: GatewayFailure; detail?: string }>; +}) { + const [input, setInput] = useState(INITIAL); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const prefix = useId(); + const id = (name: string) => `${prefix}-${name}`; + const employees = fenster.resources.employees.filter((e) => e.active); + const worksites = fenster.resources.worksites.filter((w) => w.active); + const complete = + input.worksiteId !== "" && + input.datum !== "" && + input.beginn !== "" && + input.ende !== "" && + input.employeeIds.length > 0; + const change = (field: "worksiteId" | "datum" | "beginn" | "ende", value: string) => { + setMessage(null); + setInput((old) => ({ ...old, [field]: value })); + }; + if (employees.length === 0 || worksites.length === 0) { + return ( +
+

Baustellentag planen

+

+ {employees.length === 0 && worksites.length === 0 + ? "Für diese Organisation sind weder aktive Mitarbeitende noch aktive Baustellen hinterlegt." + : employees.length === 0 + ? "Für diese Organisation sind keine aktiven Mitarbeitenden hinterlegt." + : "Für diese Organisation sind keine aktiven Baustellen hinterlegt."} +

+
+ ); + } + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!complete || busy) return; + const result = zuIntervall(input, fenster.timeZone); + if (!result.ok) { + setMessage({ ok: false, text: UMRECHNUNG_TEXT[result.fehler] }); + return; + } + setBusy(true); + setMessage(null); + try { + const response = await onSubmit({ + worksiteId: input.worksiteId, + localDate: input.datum, + team: [...input.employeeIds].sort().map((employeeId) => ({ + employeeId, + interval: { startUtc: result.startUtc, endUtc: result.endUtc }, + })), + }); + if (response.ok) { + setInput(INITIAL); + setMessage({ + ok: true, + text: "Der Baustellentag wurde gespeichert und aus dem Serverstand geladen.", + }); + } else { + setMessage({ + ok: false, + text: response.detail ?? ERRORS[response.failure ?? "UNAVAILABLE"], + }); + } + } catch { + setMessage({ ok: false, text: ERRORS.UNAVAILABLE }); + } finally { + setBusy(false); + } + } + return ( +
+

Baustellentag planen

+ +
+ + +
+
+ + change("datum", e.target.value)} + /> +
+
+
+ + change("beginn", e.target.value)} + /> +
+
+ + change("ende", e.target.value)} + /> +
+
+
+ + +

+ Mehrfachauswahl — Maus: Umschalttaste für einen Bereich, Strg beziehungsweise ⌘ für + einzelne Personen. Tastatur: Pfeiltasten wechseln die Auswahl, Umschalt+Pfeil erweitert + sie, ⌘ beziehungsweise Strg+Pfeil bewegt ohne Auswahl, Leertaste wählt eine Person an + oder ab. {input.employeeIds.length} ausgewählt. +

+
+ + + {message === null ? null : ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/apps/web/e2e/auth-journey/config.ts b/apps/web/e2e/auth-journey/config.ts index 519e0bed..36f5f148 100644 --- a/apps/web/e2e/auth-journey/config.ts +++ b/apps/web/e2e/auth-journey/config.ts @@ -92,7 +92,14 @@ export default defineConfig({ fullyParallel: false, forbidOnly: true, retries: 0, - timeout: 60_000, + // Die Kernreise ist EIN Test mit Dutzenden Schritten. Gemessen 06.09.2026 + // (colima, M-Serie, drei Laeufe): 43 s vor EYT-158, 60–84 s mit den + // zusaetzlichen axe-, Tastatur- und Zweitkontext-Nachweisen des + // Baustellentag-Schritts — bei 60 s fiel der Lauf am LETZTEN Schritt + // (ungueltige Sitzung) ins Timeout, obwohl jeder Nachweis bestanden hatte. + // Ein Deckel ist kein Ziel: grosszuegig, weil Ubuntu-Runner nicht schneller + // sind und ein Timeout hier stets den falschen Schritt beschuldigt. + timeout: 180_000, outputDir: join(ARTEFAKTE, "artefakte"), reporter: [ ["list"], diff --git a/apps/web/e2e/auth-journey/fixtures.sql b/apps/web/e2e/auth-journey/fixtures.sql index 2e1282d1..9eae2bc9 100644 --- a/apps/web/e2e/auth-journey/fixtures.sql +++ b/apps/web/e2e/auth-journey/fixtures.sql @@ -90,6 +90,22 @@ values ('00000000-0000-4000-8000-00000000e212', on conflict (id) do update set display_name = excluded.display_name, active = excluded.active; +-- --------------------------------------------------------------------------- +-- Der dritte Mitarbeiter fuer das Einsatzteam (EYT-158) +-- --------------------------------------------------------------------------- +-- Die Reise legt einen Baustellentag worksite-first mit DREI Personen in EINEM +-- Command an (Akzeptanzkriterium „mindestens drei Testmitarbeiter"). e211 und +-- e212 allein waeren zwei. Bewusst OHNE Stundensatz und ohne Einplanung in +-- W32/W33: er beruehrt weder die abgenommenen Kostensummen noch die +-- Satz-fehlt-Reise — e212 bleibt der kanonische Satz-fehlt-Mitarbeiter, und +-- `satz_ohne` unten zaehlt weiterhin ausschliesslich ihn. +insert into public.employees (id, org_id, user_id, display_name, active) +values ('00000000-0000-4000-8000-00000000e214', + '00000000-0000-4000-8000-00000000e201', + null, 'E2E-Mitarbeiter Team Drei', true) +on conflict (id) do update + set display_name = excluded.display_name, active = excluded.active; + -- --------------------------------------------------------------------------- -- Planungsdaten fuer die Publish-Reise (EYT-107) -- --------------------------------------------------------------------------- @@ -226,17 +242,19 @@ begin -- -- Seit EYT-146 zwei Baustellen, zwei Planversionen (beide Entwurf) und drei -- Zuweisungen: eine in W32 fuer die Reise, zwei in W33 fuer den - -- Baustellenfilter. Seit EYT-142 ZWEI Mitarbeiter: e211 mit genau einer + -- Baustellenfilter. Seit EYT-142 zwei Mitarbeiter: e211 mit genau einer -- Satzversion, e212 mit exakt NULL — `satz_ohne=0` ist die definierende -- Eigenschaft der Satz-fehlt-Reise und wird hier fail-closed nachgerechnet. + -- Seit EYT-158 DREI: e214 ohne Satz und ohne Einplanung, nur als drittes + -- Teammitglied des Baustellentag-Nachweises. -- Die Zahlen stehen ausgeschrieben und nicht als „>= 1", -- damit eine versehentlich doppelt eingespielte Fixtur auffaellt statt den -- Betrag eines Snapshots still zu verdoppeln. - if n_mitglied <> 1 or n_mitarbeiter <> 2 or n_satz <> 1 or n_satz_ohne <> 0 + if n_mitglied <> 1 or n_mitarbeiter <> 3 or n_satz <> 1 or n_satz_ohne <> 0 or n_projektion <> 2 or n_baustelle <> 2 or n_version <> 2 or n_entwurf <> 2 or n_zuweisung <> 3 then raise exception - 'E2E-Fixture unvollstaendig: membership=% mitarbeiter=% satz=% satz_ohne=% projektionen=% baustelle=% version=% entwurf=% zuweisung=% (erwartet 1/2/1/0/2/2/2/2/3)', + 'E2E-Fixture unvollstaendig: membership=% mitarbeiter=% satz=% satz_ohne=% projektionen=% baustelle=% version=% entwurf=% zuweisung=% (erwartet 1/3/1/0/2/2/2/2/3)', n_mitglied, n_mitarbeiter, n_satz, n_satz_ohne, n_projektion, n_baustelle, n_version, n_entwurf, n_zuweisung; end if; diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts index 36101f5c..9272849f 100644 --- a/apps/web/e2e/auth-journey/journey.pwtest.ts +++ b/apps/web/e2e/auth-journey/journey.pwtest.ts @@ -1259,6 +1259,181 @@ test("Reale Auth-Kostenreise vom Login bis zur ungueltigen Sitzung", async ({ schritte["9c1_werkbank_breiten"] = { breiten: werkbankBreiten.map((b) => b.width) }; }); + // --------------------------------------------------------------------- + // 9c1b — EYT-158: ein Baustellentag entsteht worksite-first ueber den + // Inspector der Werkbank, mit DREI Personen als Einsatzteam + // --------------------------------------------------------------------- + // 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. + // + // Seit EYT-158 ist der Create-Flow worksite-first und der Command EIN + // Baustellentag (`POST /planung/baustellentage`), nicht drei + // Einzelzuweisungen: Baustelle -> Tag -> Arbeitszeit -> Einsatzteam. Sichtbar + // wird GENAU EINE Karte mit dem Team darunter, und Erfolg gibt es erst nach + // bestaetigtem Readback. e214 existiert nur fuer diesen Nachweis + // (fixtures.sql); e212 bleibt der Satz-fehlt-Mitarbeiter und traegt in W34 + // keine Kostenwirkung. + await test.step("9c1b — EYT-158: Baustellentag worksite-first mit drei Personen anlegen", async () => { + const BAUSTELLE = "00000000-0000-4000-8000-00000000e241"; + const TAG = "2026-08-18"; + const EINSATZTEAM = [ + "00000000-0000-4000-8000-00000000e211", + "00000000-0000-4000-8000-00000000e212", + "00000000-0000-4000-8000-00000000e214", + ]; + const TEAM_NAMEN = [ + "E2E-Mitarbeiter Reise", + "E2E-Mitarbeiter Ohne Satz", + "E2E-Mitarbeiter Team Drei", + ]; + interface Baustellentag { + readonly worksiteDayId: string; + readonly configurationId: string; + readonly team: ReadonlyArray<{ readonly assignmentId: string; readonly employeeId: string }>; + } + /** Genau EINE Karte am Tag, das Team untergeordnet, Namen statt Ids. */ + const karteGeprueft = async (seite: Page, tag: Baustellentag): Promise => { + const karte = seite.locator(`.einsatzkarte[data-worksite-day-id="${tag.worksiteDayId}"]`); + await expect(karte).toBeVisible(); + await expect(seite.locator(`[data-tag="${TAG}"] .einsatzkarte`)).toHaveCount(1); + await expect(karte).toHaveAttribute("data-configuration-id", tag.configurationId); + await expect(seite.locator(`[data-tag="${TAG}"] [data-assignment-id]`)).toHaveCount(3); + for (const mitglied of tag.team) { + await expect( + karte.locator(`[data-assignment-id="${mitglied.assignmentId}"]`), + ).toBeVisible(); + } + await expect(karte).toContainText("E2E-Baustelle Reise"); + await expect(karte).toContainText("07:00–09:00"); + for (const name of TEAM_NAMEN) await expect(karte).toContainText(name); + // Technische Ids sind keine sichtbare Identitaet. + const text = await karte.innerText(); + expect(text).not.toContain(tag.worksiteDayId); + expect(text).not.toContain(tag.configurationId); + for (const mitglied of tag.team) expect(text).not.toContain(mitglied.assignmentId); + }; + + // 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(); + // Worksite-first: der Fokus landet auf der Baustelle, nicht auf einer + // Person; die Arbeitszeit ist mit 08:00–18:00 vorbelegt und wird gleich + // bearbeitet. + await expect(page.getByTestId("feld-worksite")).toBeFocused(); + await expect(page.getByTestId("feld-beginn")).toHaveValue("08:00"); + await expect(page.getByTestId("feld-ende")).toHaveValue("18:00"); + await page.screenshot({ + path: join(ARTEFAKTE, "12-werkbank-inspector-1440.png"), + fullPage: true, + }); + + await page.getByTestId("feld-worksite").selectOption(BAUSTELLE); + await page.getByTestId("feld-datum").fill(TAG); + await page.getByTestId("feld-beginn").fill("07:00"); + await page.getByTestId("feld-ende").fill("09:00"); + await page.getByTestId("feld-employee").selectOption(EINSATZTEAM); + const [schreibAntwort] = await Promise.all([ + page.waitForResponse( + (antwort) => + antwort.url().includes("/planung/baustellentage") && + antwort.request().method() === "POST", + ), + page.getByTestId("einsatz-speichern").click(), + ]); + expect(schreibAntwort.status()).toBe(201); + const angelegt = (await schreibAntwort.json()) as Baustellentag; + expect(angelegt.worksiteDayId).toMatch(/^[0-9a-f-]{36}$/); + expect(angelegt.configurationId).toMatch(/^[0-9a-f-]{36}$/); + // EIN Command, drei Personen. + expect(angelegt.team.map((m) => m.employeeId).sort()).toEqual([...EINSATZTEAM].sort()); + + // Sichtbar wird der BESTAETIGTE Serverstand (Read-through): genau eine + // Baustellentag-Karte am Dienstag, 18.08., das Team untergeordnet — und + // die Erfolgsmeldung erst NACH dem passenden Readback. + await karteGeprueft(page, angelegt); + await expect(page.getByTestId("einsatzformular-meldung")).toHaveAttribute( + "data-state", + "erfolg", + ); + await page.screenshot({ + path: join(ARTEFAKTE, "13-werkbank-baustellentag-serverbestaetigt-1440.png"), + fullPage: true, + }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.screenshot({ + path: join(ARTEFAKTE, "13-werkbank-baustellentag-serverbestaetigt-1920.png"), + fullPage: true, + }); + await page.setViewportSize({ width: 1440, height: 900 }); + + // Barrierefreiheit der INTEGRIERTEN Flaeche — Karte plus offener Inspector + // mit Mehrfachauswahl: axe und Reflow bei 1440/1920/200 %, Tastatur ohne + // Falle, sichtbarer Fokus, hoechstens eine primaere Aktion. + await pruefeBarrierefreiheit(page, "/planung W34 mit Baustellentag-Karte und Inspector"); + + // Reload: dieselben serverseitigen Ids, und die vorher versionslose Woche + // ist jetzt eindeutig als ENTWURF erkennbar. + await page.reload(); + await karteGeprueft(page, angelegt); + await expect(page.getByTestId("planungsfenster-stand")).toHaveAttribute( + "data-stand", + "entwurf", + ); + + // Zweiter Browserkontext mit eigener Anmeldung: der Zustand liegt im + // Server, nicht im Browser des Bearbeiters. + const zweiter = await page.context().browser()?.newContext(); + if (zweiter === undefined) throw new Error("[auth-journey] kein zweiter Browserkontext."); + try { + const seite2 = await zweiter.newPage(); + await seite2.goto("/anmelden"); + await seite2.getByLabel("E-Mail").fill(email); + await seite2.getByLabel("Passwort").fill(passwort); + await seite2.getByRole("button", { name: "Anmelden" }).click(); + await seite2.waitForURL((u) => !u.pathname.startsWith("/anmelden")); + await seite2.goto("/planung?weekKey=2026-W34"); + await karteGeprueft(seite2, angelegt); + } finally { + await zweiter.close(); + } + + // 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_eyt158_baustellentag"] = { + worksiteDayId: angelegt.worksiteDayId, + configurationId: angelegt.configurationId, + assignmentIds: angelegt.team.map((m) => m.assignmentId), + woche: "2026-W34", + }; + }); + // --------------------------------------------------------------------- // 9c2 — Der P1-Nachweis: die Data-API veroeffentlicht NICHT (EYT-107) // --------------------------------------------------------------------- diff --git a/apps/web/e2e/auth-journey/teardown.sql b/apps/web/e2e/auth-journey/teardown.sql index 2507a216..1e3cd8df 100644 --- a/apps/web/e2e/auth-journey/teardown.sql +++ b/apps/web/e2e/auth-journey/teardown.sql @@ -51,11 +51,23 @@ begin; alter table public.plan_versions disable trigger plan_versions_published_immutable; alter table public.assignments disable trigger assignments_published_immutable; +-- Seit EYT-158 legt die Reise einen Baustellentag an (Migration 0019); auch +-- dessen Konfigurationsrevision traegt einen Unveraenderlichkeitstrigger. +alter table public.worksite_day_configurations + disable trigger worksite_day_configurations_immutable_when_published; -- Zuweisungen VOR den Mitarbeitern: `assignments.employee_id` zeigt mit -- `on delete restrict` auf `employees`. delete from public.assignments where org_id = '00000000-0000-4000-8000-00000000e201'; +-- Baustellentage VOR den Baustellen: `worksite_days.worksite_id` zeigt mit +-- `on delete restrict` auf `worksites`, und die Konfigurationsrevision auf den +-- Tag. Gemessen 06.09.2026: ohne diese beiden Zeilen brach der Teardown mit +-- `worksite_days_worksite_id_org_id_fkey` ab — der erste Lauf nach EYT-158. +delete from public.worksite_day_configurations + where org_id = '00000000-0000-4000-8000-00000000e201'; +delete from public.worksite_days + where org_id = '00000000-0000-4000-8000-00000000e201'; delete from public.plan_versions where org_id = '00000000-0000-4000-8000-00000000e201'; -- Kostensnapshots VOR Baustellen, Mitarbeitern und Satzversionen (EYT-144): @@ -93,6 +105,8 @@ delete from public.users delete from auth.users where email in (:'reisender_a', :'reisender_b'); +alter table public.worksite_day_configurations + enable trigger worksite_day_configurations_immutable_when_published; alter table public.assignments enable trigger assignments_published_immutable; alter table public.plan_versions enable trigger plan_versions_published_immutable; @@ -146,6 +160,11 @@ begin select 1 from public.assignments where org_id = '00000000-0000-4000-8000-00000000e201' union all select 1 from public.worksites where org_id = '00000000-0000-4000-8000-00000000e201' + union all + select 1 from public.worksite_days where org_id = '00000000-0000-4000-8000-00000000e201' + union all + select 1 from public.worksite_day_configurations + where org_id = '00000000-0000-4000-8000-00000000e201' ) as planung; -- Seit EYT-144 erzeugt die Reise einen Kosten-Snapshot. Ohne diese Zaehlung @@ -172,18 +191,19 @@ begin -- abgebrochener Lauf sie stillschweigend abgeschaltet zuruecklassen, und -- jede spaetere Suite haette eine Invariante weniger — gruen, aber blind. select count(*) into n_trigger from pg_trigger - where tgname in ('plan_versions_published_immutable', 'assignments_published_immutable') + where tgname in ('plan_versions_published_immutable', 'assignments_published_immutable', + 'worksite_day_configurations_immutable_when_published') and tgenabled = 'O'; rest := n_auth + n_projektion + n_org + n_mitglied + n_mitarbeiter + n_satz + n_planung + n_kosten + n_wirkung; - if rest <> 0 or n_trigger <> 2 then + if rest <> 0 or n_trigger <> 3 then raise exception - 'E2E-Teardown unvollstaendig: auth=% projektion=% org=% membership=% mitarbeiter=% satz=% planung=% kosten=% wirkung=% aktive_trigger=% (erwartet 2)', + 'E2E-Teardown unvollstaendig: auth=% projektion=% org=% membership=% mitarbeiter=% satz=% planung=% kosten=% wirkung=% aktive_trigger=% (erwartet 3)', n_auth, n_projektion, n_org, n_mitglied, n_mitarbeiter, n_satz, n_planung, n_kosten, n_wirkung, n_trigger; end if; raise notice - '[auth-journey-teardown] restzeilen=0 auth=0 projektion=0 org=0 membership=0 mitarbeiter=0 satz=0 planung=0 kosten=0 wirkung=0 trigger=2'; + '[auth-journey-teardown] restzeilen=0 auth=0 projektion=0 org=0 membership=0 mitarbeiter=0 satz=0 planung=0 kosten=0 wirkung=0 trigger=3'; end $$; diff --git a/apps/web/e2e/harness/seed.sql b/apps/web/e2e/harness/seed.sql index 16fa5aec..1e026e14 100644 --- a/apps/web/e2e/harness/seed.sql +++ b/apps/web/e2e/harness/seed.sql @@ -56,6 +56,12 @@ insert into public.employees (id, org_id, user_id, display_name, active) values ('e11a0001-0001-4001-8001-000000000001', '00000000-0000-4000-8000-0000000000a1', null, 'Harness Planerin Alpha', true), + -- EYT-158: zwei weitere aktive Alpha-Mitarbeitende, damit der Browsernachweis + -- ein Einsatzteam aus DREI Personen in EINEM Baustellentag-Command anlegt. + ('e11a0002-0002-4002-8002-000000000002', '00000000-0000-4000-8000-0000000000a1', + null, 'Harness Kletterer Alpha', true), + ('e11a0003-0003-4003-8003-000000000003', '00000000-0000-4000-8000-0000000000a1', + null, 'Harness Bodenkraft Alpha', true), ('e11b0001-0001-4001-8001-000000000001', '00000000-0000-4000-8000-0000000000b2', null, 'Harness Planer Beta', true); diff --git a/apps/web/e2e/harness/verify-seed.sql b/apps/web/e2e/harness/verify-seed.sql index 202b0108..79eb6e6b 100644 --- a/apps/web/e2e/harness/verify-seed.sql +++ b/apps/web/e2e/harness/verify-seed.sql @@ -19,7 +19,22 @@ declare beta_zuweisungen int; gleichstand int; beta_bekannt int; + alpha_mitarbeitende int; begin + -- EYT-158: der Schreibpfad plant DREI Harness-Mitarbeitende als ein Team. + -- Gezaehlt werden die drei NAMENTLICHEN Ids, nicht "alle in Alpha": der + -- Standardseed (supabase/seed.sql) legt in derselben Organisation eigene + -- Mitarbeitende an — gemessen 06.09.2026, ein org-weiter Zaehler fand 4. + select count(*) into alpha_mitarbeitende + from public.employees + where org_id = '00000000-0000-4000-8000-0000000000a1' and active + and id in ('e11a0001-0001-4001-8001-000000000001', + 'e11a0002-0002-4002-8002-000000000002', + 'e11a0003-0003-4003-8003-000000000003'); + if alpha_mitarbeitende <> 3 then + raise exception 'Fixture kaputt: erwartet die 3 aktiven Harness-Mitarbeitenden von Alpha, gefunden %', alpha_mitarbeitende; + end if; + select count(*) filter (where org_id = '00000000-0000-4000-8000-0000000000a1'), count(*) filter (where org_id = '00000000-0000-4000-8000-0000000000b2') @@ -61,6 +76,6 @@ begin raise exception 'Fixture kaputt: Beta-Zuweisung b5510001 fehlt'; end if; - raise notice 'Fixture OK: 3 Alpha, 1 Beta, 2 Versionen im Gleichstand'; + raise notice 'Fixture OK: 3 Alpha-Zuweisungen, 3 Alpha-Mitarbeitende, 1 Beta, 2 Versionen im Gleichstand'; end $$; diff --git a/apps/web/e2e/read-through.spec.ts b/apps/web/e2e/read-through.spec.ts index 69c29ff4..e70a4903 100644 --- a/apps/web/e2e/read-through.spec.ts +++ b/apps/web/e2e/read-through.spec.ts @@ -469,11 +469,53 @@ test.describe("Standardseed-Abgrenzung (EYT-91)", () => { */ const A_PERSON = "e11a0001-0001-4001-8001-000000000001"; const A_BAUSTELLE = "5117a001-0001-4001-8001-000000000001"; +/** + * EYT-158: drei aktive Alpha-Mitarbeitende aus `e2e/harness/seed.sql` als EIN + * Einsatzteam. Weniger als drei bewiese das Akzeptanzkriterium „mindestens drei + * Personen in einem Command" nicht; `verify-seed.sql` zaehlt sie fail-closed. + */ +const A_TEAM = [ + A_PERSON, + "e11a0002-0002-4002-8002-000000000002", + "e11a0003-0003-4003-8003-000000000003", +] as const; +const A_TEAM_NAMEN = [ + "Harness Planerin Alpha", + "Harness Kletterer Alpha", + "Harness Bodenkraft Alpha", +] as const; +const UUID = /^[0-9a-f-]{36}$/; + +/** Antwort von `POST /planung/baustellentage` — nur die hier gepruefte Teilmenge. */ +interface BaustellentagAntwort { + readonly worksiteDayId: string; + readonly configurationId: string; + readonly team: ReadonlyArray<{ readonly assignmentId: string; readonly employeeId: string }>; +} /** Ein Slot in W40, der die geseedete Zuweisung (30.09. 06:00–10:00 UTC) NICHT beruehrt. */ const NEU_DATUM = "2026-10-01"; const NEU_BEGINN = "07:00"; const NEU_ENDE = "15:00"; +/** Der Tag der geseedeten Entwurfszuweisung von Person A — fuer den echten Personenkonflikt. */ +const KONFLIKT_DATUM = "2026-09-30"; + +/** Die im DOM gerenderten Baustellentag-Ids, sortiert (EYT-158). */ +async function sichtbareBaustellentage(seite: import("@playwright/test").Page): Promise { + const ids = await seite + .locator("[data-worksite-day-id]") + .evaluateAll((els) => els.map((e) => e.getAttribute("data-worksite-day-id") ?? "")); + return [...ids].sort(); +} + +/** Auf die ANTWORT des Baustellentag-Commands warten, nicht auf das Absenden. */ +function baustellentagAntwort( + seite: import("@playwright/test").Page, +): Promise { + return seite.waitForResponse( + (r) => r.url().includes("/planung/baustellentage") && r.request().method() === "POST", + ); +} /** * Woche oeffnen und warten, bis der SERVERSTAND wirklich da ist. @@ -490,22 +532,73 @@ 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(); +} + +/** + * Worksite-first (EYT-158): Baustelle, lokaler Tag, Arbeitszeit, DANN das + * Einsatzteam. Beginn und Ende stehen mit 08:00–18:00 vorbelegt; `fill` + * ersetzt den Wert eines time-Inputs vollstaendig. + */ async function formularAusfuellen( seite: import("@playwright/test").Page, beginn: string, ende: string, datum = NEU_DATUM, + team: readonly string[] = A_TEAM, ): Promise { - await seite.getByTestId("feld-employee").selectOption(A_PERSON); + await inspectorOeffnen(seite); await seite.getByTestId("feld-worksite").selectOption(A_BAUSTELLE); await seite.getByTestId("feld-datum").fill(datum); await seite.getByTestId("feld-beginn").fill(beginn); await seite.getByTestId("feld-ende").fill(ende); + await seite.getByTestId("feld-employee").selectOption([...team]); +} + +/** + * Die EINE Baustellentag-Karte zu einer Serverantwort pruefen: genau eine + * Karte am Tag, das Team untergeordnet, Namen statt Ids sichtbar. + */ +async function karteGeprueft( + seite: import("@playwright/test").Page, + angelegt: BaustellentagAntwort, + datum: string, + zeit: string, +): Promise { + const karte = seite.locator(`.einsatzkarte[data-worksite-day-id="${angelegt.worksiteDayId}"]`); + await expect(karte).toBeVisible(); + await expect(seite.locator(`[data-tag="${datum}"] .einsatzkarte`)).toHaveCount(1); + await expect(karte).toHaveAttribute("data-configuration-id", angelegt.configurationId); + await expect(karte.locator("[data-assignment-id]")).toHaveCount(angelegt.team.length); + for (const mitglied of angelegt.team) { + await expect(karte.locator(`[data-assignment-id="${mitglied.assignmentId}"]`)).toBeVisible(); + } + await expect(karte).toContainText("Harness Baustelle Alpha"); + await expect(karte).toContainText(zeit); + for (const name of A_TEAM_NAMEN) await expect(karte).toContainText(name); + // Technische Ids sind keine sichtbare Identitaet (EYT-158). + const text = await karte.innerText(); + expect(text).not.toContain(angelegt.worksiteDayId); + expect(text).not.toContain(angelegt.configurationId); + for (const mitglied of angelegt.team) expect(text).not.toContain(mitglied.assignmentId); } test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { @@ -513,19 +606,27 @@ 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); + + // Worksite-first: der Fokus landet auf der Baustelle, nicht auf einer + // Person, und die Arbeitszeit ist mit 08:00–18:00 vorbelegt (EYT-158). + await expect(page.getByTestId("feld-worksite")).toBeFocused(); + await expect(page.getByTestId("feld-beginn")).toHaveValue("08:00"); + await expect(page.getByTestId("feld-ende")).toHaveValue("18:00"); // Die Namen stammen aus e2e/harness/seed.sql und existieren nirgends im // Clientcode — waeren sie eine Fixture, stuende hier ein anderer Text. const personen = page.getByTestId("feld-employee"); - await expect(personen).toContainText("Harness Planerin Alpha"); + await expect(personen).toHaveAttribute("multiple", ""); + for (const name of A_TEAM_NAMEN) await expect(personen).toContainText(name); await expect(page.getByTestId("feld-worksite")).toContainText("Harness Baustelle Alpha"); // Werte sind die SERVERSEITIGEN Ids, nicht die Namen. const werte = await personen .locator("option") .evaluateAll((els) => els.map((e) => e.getAttribute("value") ?? "")); - expect(werte).toContain(A_PERSON); + for (const id of A_TEAM) expect(werte).toContain(id); // Und kein Byte aus Organisation B — weder Id noch Name. const markup = (await page.content()).toLowerCase(); @@ -537,71 +638,113 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { test("Schritt 2: unvollstaendige Eingabe loest keinen Schreibaufruf aus", async ({ page }) => { const schreibaufrufe: string[] = []; page.on("request", (req) => { - if (req.method() === "POST" && req.url().includes("/planung/einsaetze")) { + if (req.method() === "POST" && req.url().includes("/planung/")) { schreibaufrufe.push(req.url()); } }); await page.goto(SEITE); - await page.getByTestId("feld-employee").selectOption(A_PERSON); + await inspectorOeffnen(page); await page.getByTestId("feld-worksite").selectOption(A_BAUSTELLE); - // Datum, Beginn und Ende fehlen absichtlich. + await page.getByTestId("feld-employee").selectOption([...A_TEAM]); + // Das Datum fehlt absichtlich; Beginn und Ende sind vorbelegt. await page.getByTestId("einsatz-speichern").click({ force: true }); await page.waitForTimeout(500); expect(schreibaufrufe).toEqual([]); }); - test("Schritt 3: ein gueltiger Entwurf wird serverseitig gespeichert und sichtbar", async ({ + test("Schritt 3: ein Baustellentag mit drei Personen wird serverseitig gespeichert und als EINE Karte sichtbar", async ({ page, }) => { const vorher = await wocheOeffnen(page); + const tageVorher = await sichtbareBaustellentage(page); await formularAusfuellen(page, NEU_BEGINN, NEU_ENDE); const [antwort] = await Promise.all([ - page.waitForResponse( - (r) => r.url().includes("/planung/einsaetze") && r.request().method() === "POST", - ), + baustellentagAntwort(page), page.getByTestId("einsatz-speichern").click(), ]); expect(antwort.status()).toBe(201); - // Die Id kommt vom SERVER, nicht aus dem Browser. - const angelegt = (await antwort.json()) as { id: string }; - expect(angelegt.id).toMatch(/^[0-9a-f-]{36}$/); + // Die Ids kommen vom SERVER, nicht aus dem Browser — und es ist EIN Command + // fuer drei Personen, nicht drei Commands. + const angelegt = (await antwort.json()) as BaustellentagAntwort; + expect(angelegt.worksiteDayId).toMatch(UUID); + expect(angelegt.configurationId).toMatch(UUID); + expect(angelegt.team.map((m) => m.employeeId).sort()).toEqual([...A_TEAM].sort()); + for (const mitglied of angelegt.team) expect(mitglied.assignmentId).toMatch(UUID); + + // Sichtbar wird der NEU GELESENE Serverstand, nicht die Schreibantwort: + // genau eine Karte am Tag, das Team untergeordnet. + await karteGeprueft(page, angelegt, NEU_DATUM, `${NEU_BEGINN}–${NEU_ENDE}`); + + // Erfolg heisst Write UND passender Readback (EYT-158). + const meldung = page.getByTestId("einsatzformular-meldung"); + await expect(meldung).toHaveAttribute("data-state", "erfolg"); + await expect(meldung).toContainText(/Serverstand/i); - // Und sie steht anschliessend im DOM — ueber einen NEUEN Lesevorgang, - // nicht durch optimistisches Einfuegen. - await expect(page.locator(`[data-assignment-id="${angelegt.id}"]`)).toBeVisible(); const nachher = await sichtbareZuweisungen(page); - expect(nachher).toHaveLength(vorher.length + 1); - expect(nachher).toContain(angelegt.id); + expect(nachher).toHaveLength(vorher.length + 3); + for (const mitglied of angelegt.team) expect(nachher).toContain(mitglied.assignmentId); + expect(await sichtbareBaustellentage(page)).toEqual( + [...tageVorher, angelegt.worksiteDayId].sort(), + ); }); - test("Schritt 4: ein ueberlappender Entwurf wird mit verstaendlichem Grund abgelehnt", async ({ + test("Schritt 4: ein zweiter Baustellentag fuer dieselbe Baustelle und denselben Tag wird mit verstaendlichem Grund abgelehnt", async ({ page, }) => { const vorher = await wocheOeffnen(page); + const tageVorher = await sichtbareBaustellentage(page); - // Genau derselbe Slot wie im vorigen Test — die Ueberlappung ist echt. + // Genau dieselbe Baustelle und derselbe Tag wie im vorigen Test — der + // Konflikt (DUPLICATE_WORKSITE_DAY) ist echt, nicht vorbereitet. await formularAusfuellen(page, NEU_BEGINN, NEU_ENDE); const [antwort] = await Promise.all([ - page.waitForResponse( - (r) => r.url().includes("/planung/einsaetze") && r.request().method() === "POST", - ), + baustellentagAntwort(page), page.getByTestId("einsatz-speichern").click(), ]); expect(antwort.status()).toBe(409); - // Verstaendlicher Grund, nicht nur ein Statuscode. + // Verstaendlicher Grund, nicht nur ein Statuscode — und keine erfundene + // Karte aus einer Ablehnung. const meldung = page.getByTestId("einsatzformular-meldung"); await expect(meldung).toBeVisible(); - await expect(meldung).toContainText(/bereits eingeplant/i); + await expect(meldung).toHaveAttribute("data-state", "fehler"); + await expect(meldung).toContainText(/bereits ein Baustellentag/i); + expect(await sichtbareBaustellentage(page)).toEqual(tageVorher); // Keine Teilwirkung: nach dem Reload steht die Liste unveraendert. // `wocheOeffnen` statt `reload`, damit hier dieselbe Wartebedingung gilt // wie oben — sonst vergliche der Test den Ladezustand mit dem Endzustand. expect(await wocheOeffnen(page)).toEqual(vorher); + expect(await sichtbareBaustellentage(page)).toEqual(tageVorher); + }); + + test("Schritt 4: eine bereits eingeplante Person macht den ganzen Baustellentag atomar ungueltig", async ({ + page, + }) => { + const vorher = await wocheOeffnen(page); + const tageVorher = await sichtbareBaustellentage(page); + + // Person A traegt am 30.09. die geseedete Entwurfszuweisung 06:00–10:00Z. + // 07:00–15:00 Europe/Berlin ueberlappt sie; die beiden anderen Personen + // sind frei. Ein Team ist EIN Command: keine zwei duerfen stehen bleiben. + await formularAusfuellen(page, NEU_BEGINN, NEU_ENDE, KONFLIKT_DATUM); + const [antwort] = await Promise.all([ + baustellentagAntwort(page), + page.getByTestId("einsatz-speichern").click(), + ]); + expect(antwort.status()).toBe(409); + + const meldung = page.getByTestId("einsatzformular-meldung"); + await expect(meldung).toHaveAttribute("data-state", "fehler"); + await expect(meldung).toContainText(/bereits eingeplant/i); + + // Weder Karte noch Teilteam: derselbe Serverstand wie vorher. + expect(await wocheOeffnen(page)).toEqual(vorher); + expect(await sichtbareBaustellentage(page)).toEqual(tageVorher); }); test("Schritt 4: ein ungueltiges Intervall erreicht den Server gar nicht erst", async ({ @@ -609,7 +752,7 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { }) => { const schreibaufrufe: string[] = []; page.on("request", (req) => { - if (req.method() === "POST" && req.url().includes("/planung/einsaetze")) { + if (req.method() === "POST" && req.url().includes("/planung/")) { schreibaufrufe.push(req.url()); } }); @@ -630,12 +773,15 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { browser, }) => { const nachSpeichern = await wocheOeffnen(page); + const tage = await sichtbareBaustellentage(page); const provenienzVorher = await provenienz(page); - // Der Speichertest hat genau eine Zeile ergaenzt; steht sie nicht mehr da, - // war sie nie in PostgreSQL. - expect(nachSpeichern.length).toBeGreaterThan(1); + // Der Speichertest hat drei Zeilen und genau einen Baustellentag ergaenzt; + // stehen sie nicht mehr da, waren sie nie in PostgreSQL. + expect(nachSpeichern.length).toBeGreaterThan(3); + expect(tage.length).toBeGreaterThan(0); expect(await wocheOeffnen(page)).toEqual(nachSpeichern); + expect(await sichtbareBaustellentage(page)).toEqual(tage); expect(await provenienz(page)).toEqual(provenienzVorher); const origin = new URL(page.url()).origin; @@ -643,6 +789,7 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { try { const zweiteSeite = await zweiter.newPage(); expect(await wocheOeffnen(zweiteSeite)).toEqual(nachSpeichern); + expect(await sichtbareBaustellentage(zweiteSeite)).toEqual(tage); expect(await provenienz(zweiteSeite)).toEqual(provenienzVorher); } finally { await zweiter.close(); @@ -650,6 +797,11 @@ test.describe.serial("Schreibpfad: Browser bis PostgreSQL", () => { }); }); +/** + * Zwei Faelle, zwei TAGE: dieselbe Baustelle am selben Tag waere seit EYT-158 + * kein zweiter Einsatz mehr, sondern ein Duplikat des Baustellentags — der + * zweite Viewport saehe nur den Konflikt des ersten. + */ const RESPONSIVE_CASES = [ { name: "1440px", @@ -661,7 +813,7 @@ const RESPONSIVE_CASES = [ { name: "375px", viewport: { width: 375, height: 812 }, - datum: "2026-10-02", + datum: "2026-10-03", beginn: "10:00", ende: "12:00", }, @@ -707,7 +859,7 @@ async function tabZumNaechstenFormfeld( * EYT-104: dieselbe reale Kernreise in Desktop- und mobiler Kernbreite. * * Kein zweites Mock-E2E: beide Fälle schreiben über Web-Origin, NestJS, RLS - * und PostgreSQL. Unterschiedliche Slots verhindern, dass der zweite Viewport + * und PostgreSQL. Unterschiedliche Tage verhindern, dass der zweite Viewport * nur den Konflikt des ersten sieht. */ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", () => { @@ -717,28 +869,30 @@ 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"); const datum = page.getByTestId("feld-datum"); const beginn = page.getByTestId("feld-beginn"); const ende = page.getByTestId("feld-ende"); + const employee = page.getByTestId("feld-employee"); const speichern = page.getByTestId("einsatz-speichern"); - await expect(employee).toHaveAccessibleName("Mitarbeitende"); await expect(worksite).toHaveAccessibleName("Baustelle"); await expect(datum).toHaveAccessibleName("Datum"); await expect(beginn).toHaveAccessibleName(/Beginn/); await expect(ende).toHaveAccessibleName(/Ende/); - await expect(speichern).toHaveAccessibleName("Entwurf speichern"); + await expect(employee).toHaveAccessibleName(/Einsatzteam.*Mitarbeitende/); + await expect(speichern).toHaveAccessibleName("Baustellentag speichern"); await formularAusfuellen(page, fall.beginn, fall.ende, fall.datum); - // Fokusreihenfolge innerhalb des Formulars, jeweils mit sichtbarem - // Indikator. Ein programmatischer Start am ersten Feld vermeidet, dass - // Headernavigation mit der fachlichen Reihenfolge verwechselt wird. - await employee.focus(); - const fokusziele = [employee, worksite, datum, beginn, ende, speichern]; + // Fokusreihenfolge innerhalb des Formulars — worksite-first (EYT-158) —, + // jeweils mit sichtbarem Indikator. Ein programmatischer Start am ersten + // Feld vermeidet, dass Headernavigation mit der fachlichen Reihenfolge + // verwechselt wird. + await worksite.focus(); + const fokusziele = [worksite, datum, beginn, ende, employee, speichern]; for (const [index, ziel] of fokusziele.entries()) { await expect(ziel).toBeFocused(); const focus = await ziel.evaluate((element) => { @@ -765,16 +919,11 @@ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", () }); expect(overflowBefore).toBeLessThanOrEqual(0); - const [createdResponse] = await Promise.all([ - page.waitForResponse( - (response) => - response.url().includes("/planung/einsaetze") && response.request().method() === "POST", - ), - speichern.click(), - ]); + const [createdResponse] = await Promise.all([baustellentagAntwort(page), speichern.click()]); expect(createdResponse.status()).toBe(201); - const created = (await createdResponse.json()) as { id: string }; - await expect(page.locator(`[data-assignment-id="${created.id}"]`)).toBeVisible(); + const created = (await createdResponse.json()) as BaustellentagAntwort; + expect(created.team).toHaveLength(3); + await karteGeprueft(page, created, fall.datum, `${fall.beginn}–${fall.ende}`); const success = page.getByTestId("einsatzformular-meldung"); await expect(success).toHaveAttribute("role", "status"); @@ -782,14 +931,11 @@ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", () await expect(success).toContainText(/gespeichert/i); await wocheOeffnen(page); - await expect(page.locator(`[data-assignment-id="${created.id}"]`)).toBeVisible(); + await karteGeprueft(page, created, fall.datum, `${fall.beginn}–${fall.ende}`); await formularAusfuellen(page, fall.beginn, fall.ende, fall.datum); const [conflictResponse] = await Promise.all([ - page.waitForResponse( - (response) => - response.url().includes("/planung/einsaetze") && response.request().method() === "POST", - ), + baustellentagAntwort(page), page.getByTestId("einsatz-speichern").click(), ]); expect(conflictResponse.status()).toBe(409); @@ -797,11 +943,13 @@ test.describe.serial("Planungsroute: Responsive- und Accessibility-Abnahme", () const alert = page.getByTestId("einsatzformular-meldung"); await expect(alert).toHaveAttribute("role", "alert"); await expect(alert).toHaveAttribute("data-state", "fehler"); - await expect(alert).toContainText(/bereits eingeplant/i); + await expect(alert).toContainText(/bereits ein Baustellentag/i); const describedBy = await page .getByTestId("einsatzformular") .getAttribute("aria-describedby"); expect(describedBy).toBe(await alert.getAttribute("id")); + // Die Ablehnung erfindet keine zweite Karte. + await expect(page.locator(`[data-tag="${fall.datum}"] .einsatzkarte`)).toHaveCount(1); const overflowAfter = await page.evaluate(() => { const root = document.scrollingElement ?? document.documentElement; diff --git a/apps/web/e2e/staging/journey.pwtest.ts b/apps/web/e2e/staging/journey.pwtest.ts index 9270db5d..c813a9fb 100644 --- a/apps/web/e2e/staging/journey.pwtest.ts +++ b/apps/web/e2e/staging/journey.pwtest.ts @@ -127,26 +127,51 @@ async function axeUndReflow(page: Page, flaeche: string): Promise { await page.setViewportSize({ width: 1440, height: 900 }); } -/** Legt einen Einsatz ueber das Formular an und liefert die Server-Id. */ +/** + * Legt einen Baustellentag mit EINER Person ueber das Formular an und liefert + * die Server-Id der entstandenen Einplanung. + * + * Seit EYT-158 ist der Create-Flow worksite-first: Baustelle, Tag, Arbeitszeit, + * dann das Einsatzteam; der Command geht an `POST /planung/baustellentage` und + * antwortet mit dem Baustellentag, nicht mit einer Einzelzuweisung. Die + * Kernreise braucht weiterhin genau eine Person je Tag (Satz-fehlt-Reise, + * Snapshot-Summen), deshalb bleibt das Team hier einelementig. + */ async function einsatzAnlegen( page: Page, eingabe: { mitarbeiter: string; baustelle: string; datum: string }, ): Promise { - await page.getByTestId("feld-employee").selectOption({ label: eingabe.mitarbeiter }); + // EYT-147: das Formular steht im Inspector und ist erst nach „Baustellentag + // 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-worksite").selectOption({ label: eingabe.baustelle }); await page.getByTestId("feld-datum").fill(eingabe.datum); await page.getByTestId("feld-beginn").fill("08:00"); await page.getByTestId("feld-ende").fill("12:00"); + await page.getByTestId("feld-employee").selectOption({ label: eingabe.mitarbeiter }); const antwort = page.waitForResponse( (r) => - new URL(r.url()).pathname === "/api/v1/planung/einsaetze" && r.request().method() === "POST", + new URL(r.url()).pathname === "/api/v1/planung/baustellentage" && + r.request().method() === "POST", ); await page.getByTestId("einsatz-speichern").click(); const post = await antwort; - expect(post.status(), "Einsatz anlegen").toBe(201); - const einsatz = (await post.json()) as { id: string }; - expect(einsatz.id).not.toBe(""); - return einsatz.id; + expect(post.status(), "Baustellentag anlegen").toBe(201); + const tag = (await post.json()) as { + worksiteDayId: string; + team: { assignmentId: string }[]; + }; + expect(tag.worksiteDayId).not.toBe(""); + expect(tag.team, "genau eine Person im Team dieser Kernreise").toHaveLength(1); + const einplanung = tag.team[0]?.assignmentId ?? ""; + expect(einplanung).not.toBe(""); + // Sichtbar wird der bestaetigte Serverstand: genau EINE Karte fuer den Tag. + await expect( + page.locator(`.einsatzkarte[data-worksite-day-id="${tag.worksiteDayId}"]`), + ).toBeVisible(); + return einplanung; } test.describe.serial("EYT-142 Staging-Kernreise", () => { @@ -288,8 +313,8 @@ test.describe.serial("EYT-142 Staging-Kernreise", () => { const budget = 120; const erreicht: string[] = []; let fokusPublish = false; - let fokusEmployee = false; - for (let i = 0; i < budget && !(fokusPublish && fokusEmployee); i += 1) { + let fokusFormular = false; + for (let i = 0; i < budget && !(fokusPublish && fokusFormular); i += 1) { await seite.keyboard.press("Tab"); const testid = await seite.evaluate( () => document.activeElement?.getAttribute("data-testid") ?? "", @@ -300,9 +325,21 @@ test.describe.serial("EYT-142 Staging-Kernreise", () => { // Screenshot MIT sichtbarem Fokusring auf der tragenden Aktion. await schnappschuss(seite, "06-fokus-publish-1440"); } - if (testid === "feld-employee") fokusEmployee = true; + if (testid === "feld-worksite") fokusFormular = true; + // EYT-147: das Formular liegt im Inspector. Erreicht die Tastatur den + // Ausloeser, oeffnet Enter ihn, und der Fokus landet programmatisch im + // ersten Formularfeld — seit EYT-158 die Baustelle (worksite-first), + // nicht mehr eine Person. Genau das wird hier mitgemessen. + if (testid === "werkbank-einsatz-anlegen" && !fokusFormular) { + await seite.keyboard.press("Enter"); + const nachOeffnen = await seite.evaluate( + () => document.activeElement?.getAttribute("data-testid") ?? "", + ); + if (nachOeffnen !== "") erreicht.push(nachOeffnen); + if (nachOeffnen === "feld-worksite") fokusFormular = true; + } } - expect(fokusEmployee, `Formularfeld per Tastatur in <=${budget} Tabs`).toBe(true); + expect(fokusFormular, `Baustellenfeld per Tastatur in <=${budget} Tabs`).toBe(true); expect(fokusPublish, `Publish-Knopf per Tastatur in <=${budget} Tabs`).toBe(true); halte("tastatur_erreichte_testids", erreicht); }); 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..9039fa8b --- /dev/null +++ b/apps/web/test/dispositionswerkbank.test.tsx @@ -0,0 +1,222 @@ +/** + * 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 { + // EYT-147 M2: neue Portmethoden. Diese Ansicht ruft sie nicht auf — + // ein Wurf faellt auf, eine stille Leerantwort nicht. + planWorksiteDay: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + updateWorksiteDayTeam: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + 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-worksite")); + }); + + 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/helpers/werkbank.tsx b/apps/web/test/helpers/werkbank.tsx index 62c40dda..6bf095d8 100644 --- a/apps/web/test/helpers/werkbank.tsx +++ b/apps/web/test/helpers/werkbank.tsx @@ -31,6 +31,8 @@ import type { AssignmentDto, CreateAssignmentCommand, + PlanWorksiteDayCommand, + WorksiteDayDto, PlanningWindow, PublishPlanCommand, PublishedPlanVersion, @@ -76,6 +78,7 @@ export interface NetzPlan { /** Antwort auf `GET /planung/fenster?weekKey=…`, je Woche. */ readonly fenster: (weekKey: string) => PlanningWindow | RoheAntwort; readonly einsatzAnlegen?: (befehl: CreateAssignmentCommand) => AssignmentDto | RoheAntwort; + readonly baustellentagAnlegen?: (befehl: PlanWorksiteDayCommand) => WorksiteDayDto | RoheAntwort; readonly veroeffentlichen?: (befehl: PublishPlanCommand) => PublishedPlanVersion | RoheAntwort; } @@ -145,6 +148,14 @@ export function netzSetzen(plan: NetzPlan): Netzprotokoll { : alsResponse(200, ergebnis); } + if (url.pathname === "/api/v1/planung/baustellentage" && methode === "POST") { + const ergebnis = plan.baustellentagAnlegen?.(koerper as PlanWorksiteDayCommand); + if (ergebnis === undefined) return alsResponse(501, {}); + return istRoheAntwort(ergebnis) + ? alsResponse(ergebnis.status, ergebnis.koerper) + : alsResponse(201, ergebnis); + } + if (url.pathname === "/api/v1/planung/einsaetze" && methode === "POST") { const ergebnis = plan.einsatzAnlegen?.(koerper as CreateAssignmentCommand); if (ergebnis === undefined) return alsResponse(501, {}); 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..1a522362 100644 --- a/apps/web/test/planning-window-view.test.tsx +++ b/apps/web/test/planning-window-view.test.tsx @@ -16,6 +16,14 @@ import { PlanningGatewayProvider } from "../lib/planning-gateway-provider"; function gatewayReturning(result: GatewayResult): PlanningGateway { return { + // EYT-147 M2: neue Portmethoden. Diese Ansicht ruft sie nicht auf — + // ein Wurf faellt auf, eine stille Leerantwort nicht. + planWorksiteDay: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + updateWorksiteDayTeam: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, getPlanningWindow: () => Promise.resolve(result), validateDraft: () => { throw new Error("in dieser Ansicht nicht benutzt"); @@ -71,6 +79,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 }); @@ -201,62 +219,65 @@ describe("PlanningWindowView", () => { } }); + // EYT-158: dieselben Sicherheitszusicherungen am WorksiteDay-Port. + const geschrieben = { + worksiteDayId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + configurationId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + worksiteId: PLANBARE_WOCHE.resources.worksites[0]!.id, + localDate: "2026-08-03", + lockVersion: 0, + team: [ + { + assignmentId: "11111111-1111-4111-8111-111111111111", + employeeId: PLANBARE_WOCHE.resources.employees[0]!.id, + interval: { startUtc: "2026-08-03T06:00:00.000Z", endUtc: "2026-08-03T16:00:00.000Z" }, + }, + ], + }; + const gelesen: PlanningWindow = { + ...PLANBARE_WOCHE, + worksiteDays: [geschrieben], + assignments: geschrieben.team.map((m) => ({ + id: m.assignmentId, + employeeId: m.employeeId, + worksiteId: geschrieben.worksiteId, + interval: m.interval, + })), + }; + async function ausfuellen(gateway: PlanningGateway) { + render( + + + , + ); + await inspectorOeffnen(); + await userEvent.selectOptions(screen.getByTestId("feld-worksite"), geschrieben.worksiteId); + await userEvent.type(screen.getByTestId("feld-datum"), geschrieben.localDate); + await userEvent.selectOptions( + screen.getByTestId("feld-employee"), + geschrieben.team[0]!.employeeId, + ); + } + it("behält den Idempotenzschlüssel bei einem unklaren Fehler für den Retry", async () => { const keys: string[] = []; let attempt = 0; const gateway: PlanningGateway = { - getPlanningWindow: () => Promise.resolve({ ok: true, value: PLANBARE_WOCHE }), - validateDraft: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, - createAssignment: (_input, options) => { + ...gatewayReturning({ ok: true, value: PLANBARE_WOCHE }), + planWorksiteDay: (_input, options) => { keys.push(options.idempotencyKey); attempt += 1; return Promise.resolve( attempt === 1 - ? { ok: false, failure: "UNAVAILABLE" as const, problem: null } - : { - ok: true, - value: { - id: "11111111-1111-4111-8111-111111111111", - employeeId: PLANBARE_WOCHE.resources.employees[0]?.id ?? "", - worksiteId: PLANBARE_WOCHE.resources.worksites[0]?.id ?? "", - interval: { - startUtc: "2026-08-03T06:00:00.000Z", - endUtc: "2026-08-03T14:00:00.000Z", - }, - }, - }, + ? { ok: false, failure: "UNAVAILABLE", problem: null } + : { ok: true, value: geschrieben }, ); }, - publishPlan: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, + getPlanningWindow: () => + Promise.resolve({ ok: true, value: attempt > 1 ? gelesen : PLANBARE_WOCHE }), }; - - render( - - - , - ); - await screen.findByTestId("einsatzformular"); - - await userEvent.selectOptions( - screen.getByTestId("feld-employee"), - PLANBARE_WOCHE.resources.employees[0]?.id ?? "", - ); - await userEvent.selectOptions( - screen.getByTestId("feld-worksite"), - PLANBARE_WOCHE.resources.worksites[0]?.id ?? "", - ); - await userEvent.type(screen.getByTestId("feld-datum"), "2026-08-03"); - await userEvent.type(screen.getByTestId("feld-beginn"), "08:00"); - await userEvent.type(screen.getByTestId("feld-ende"), "16:00"); - + await ausfuellen(gateway); await userEvent.click(screen.getByTestId("einsatz-speichern")); - // Genau die Meldung DES FORMULARS, nicht "irgendein Statusbereich": - // seit EYT-141 traegt auch der Leerzustand `role="status"`, und ein - // Rollenzugriff faende beide. await waitFor(() => expect(screen.getByTestId("einsatzformular-meldung").getAttribute("data-state")).toBe( "fehler", @@ -268,166 +289,65 @@ describe("PlanningWindowView", () => { "erfolg", ), ); - expect(keys).toHaveLength(2); expect(keys[1]).toBe(keys[0]); }); - it("behält die Erfolgsmeldung während des serverseitigen Read-through sichtbar", async () => { + it("bestätigt Erfolg erst nach serverseitigem Read-through und hält das Formular sichtbar", async () => { let leseversuch = 0; - const offenerReadThrough = new Promise>(() => {}); + let abschliessen!: (r: GatewayResult) => void; + const offen = new Promise>((resolve) => { + abschliessen = resolve; + }); const gateway: PlanningGateway = { - getPlanningWindow: () => { - leseversuch += 1; - return leseversuch === 1 - ? Promise.resolve({ ok: true, value: PLANBARE_WOCHE }) - : offenerReadThrough; - }, - validateDraft: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, - createAssignment: () => - Promise.resolve({ - ok: true, - value: { - id: "11111111-1111-4111-8111-111111111111", - employeeId: PLANBARE_WOCHE.resources.employees[0]?.id ?? "", - worksiteId: PLANBARE_WOCHE.resources.worksites[0]?.id ?? "", - interval: { - startUtc: "2026-08-03T06:00:00.000Z", - endUtc: "2026-08-03T14:00:00.000Z", - }, - }, - }), - publishPlan: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, + ...gatewayReturning({ ok: true, value: PLANBARE_WOCHE }), + planWorksiteDay: () => Promise.resolve({ ok: true, value: geschrieben }), + getPlanningWindow: () => + ++leseversuch === 1 ? Promise.resolve({ ok: true, value: PLANBARE_WOCHE }) : offen, }; - - render( - - - , - ); - await screen.findByTestId("einsatzformular"); - - await userEvent.selectOptions( - screen.getByTestId("feld-employee"), - PLANBARE_WOCHE.resources.employees[0]?.id ?? "", - ); - await userEvent.selectOptions( - screen.getByTestId("feld-worksite"), - PLANBARE_WOCHE.resources.worksites[0]?.id ?? "", - ); - await userEvent.type(screen.getByTestId("feld-datum"), "2026-08-03"); - await userEvent.type(screen.getByTestId("feld-beginn"), "08:00"); - await userEvent.type(screen.getByTestId("feld-ende"), "16:00"); + await ausfuellen(gateway); await userEvent.click(screen.getByTestId("einsatz-speichern")); - - const meldung = await screen.findByTestId("einsatzformular-meldung"); - await waitFor(() => expect(meldung.getAttribute("data-state")).toBe("erfolg")); - expect(meldung.textContent).toContain("Der Entwurf wurde gespeichert."); - expect(leseversuch).toBe(2); + await waitFor(() => expect(leseversuch).toBe(2)); + expect(screen.queryByTestId("einsatzformular-meldung")).toBeNull(); expect(screen.queryByTestId("planungsfenster-laedt")).toBeNull(); + expect(screen.getByTestId("einsatzformular")).toBeTruthy(); + abschliessen({ ok: true, value: gelesen }); + await waitFor(() => + expect(screen.getByTestId("einsatzformular-meldung").getAttribute("data-state")).toBe( + "erfolg", + ), + ); }); - /** - * EYT-140 M7 — `AC-007` (`REQ-004`). - * - * Der Rundlauf allein bewiese nichts: ein optimistisch eingefuegter - * Clientzustand saehe genauso aus. Deshalb tragen die drei Quellen hier - * DREI verschiedene Ids — - * - * - `GESENDETE_ID` schickt der Client nie mit (er sendet nur Person, - * Baustelle und Intervall), steht aber als Kontrollwert im Baum, - * - `SCHREIBANTWORT_ID` liefert `createAssignment` zurueck, - * - `SERVERSTAND_ID` liefert erst der ZWEITE `getPlanningWindow`. - * - * Im Markup darf nur `SERVERSTAND_ID` stehen. Stuende dort - * `SCHREIBANTWORT_ID`, zeigte die Ansicht die Schreibantwort statt des neu - * gelesenen Serverstands — und ein Schreibvorgang, dessen Antwort unterwegs - * verloren ging, saehe aus wie ein gelungener. - */ - it("zeigt nach dem Speichern den neu gelesenen Serverstand, nicht die Schreibantwort", async () => { - const SCHREIBANTWORT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + it("zeigt nach dem Speichern auch den ausschließlich im Readback vorhandenen Serverstand", async () => { const SERVERSTAND_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - const mitarbeiterId = PLANBARE_WOCHE.resources.employees[0]?.id ?? ""; - const baustellenId = PLANBARE_WOCHE.resources.worksites[0]?.id ?? ""; - const intervall = { - startUtc: "2026-08-03T06:00:00.000Z", - endUtc: "2026-08-03T14:00:00.000Z", + const read: PlanningWindow = { + ...gelesen, + assignments: [...gelesen.assignments, { ...gelesen.assignments[0]!, id: SERVERSTAND_ID }], }; - let leseversuch = 0; const gateway: PlanningGateway = { - getPlanningWindow: () => { - leseversuch += 1; - return Promise.resolve( - leseversuch === 1 - ? { ok: true, value: PLANBARE_WOCHE } - : { - ok: true, - value: { - ...PLANBARE_WOCHE, - assignments: [ - { - id: SERVERSTAND_ID, - employeeId: mitarbeiterId, - worksiteId: baustellenId, - interval: intervall, - }, - ], - }, - }, - ); - }, - validateDraft: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, - createAssignment: () => - Promise.resolve({ - ok: true, - value: { - id: SCHREIBANTWORT_ID, - employeeId: mitarbeiterId, - worksiteId: baustellenId, - interval: intervall, - }, - }), - publishPlan: () => { - throw new Error("in dieser Ansicht nicht benutzt"); - }, + ...gatewayReturning({ ok: true, value: PLANBARE_WOCHE }), + planWorksiteDay: () => Promise.resolve({ ok: true, value: geschrieben }), + getPlanningWindow: () => + Promise.resolve({ ok: true, value: ++leseversuch === 1 ? PLANBARE_WOCHE : read }), }; - - render( - - - , - ); - await screen.findByTestId("einsatzformular"); - - // Vor dem Speichern: genau EIN Lesevorgang, und noch keine Zuweisung. + await ausfuellen(gateway); expect(leseversuch).toBe(1); - expect(screen.getByTestId("planungsfenster-leer")).toBeTruthy(); - - await userEvent.selectOptions(screen.getByTestId("feld-employee"), mitarbeiterId); - await userEvent.selectOptions(screen.getByTestId("feld-worksite"), baustellenId); - await userEvent.type(screen.getByTestId("feld-datum"), "2026-08-03"); - await userEvent.type(screen.getByTestId("feld-beginn"), "08:00"); - await userEvent.type(screen.getByTestId("feld-ende"), "16:00"); await userEvent.click(screen.getByTestId("einsatz-speichern")); - - const liste = await screen.findByTestId("planungsfenster-liste"); - - // Nach dem Speichern: genau ZWEI Lesevorgaenge. + await waitFor(() => + expect(screen.getByTestId("einsatzformular-meldung").getAttribute("data-state")).toBe( + "erfolg", + ), + ); expect(leseversuch).toBe(2); - - const zeilen = liste.querySelectorAll("li[data-assignment-id]"); - expect(zeilen).toHaveLength(1); - expect(zeilen[0]?.getAttribute("data-assignment-id")).toBe(SERVERSTAND_ID); - - // Und die Schreibantwort taucht nirgends im ausgelieferten Markup auf. - expect(document.body.innerHTML).not.toContain(SCHREIBANTWORT_ID); + const ids = [ + ...screen.getByTestId("planungsfenster-liste").querySelectorAll("[data-assignment-id]"), + ].map((e) => e.getAttribute("data-assignment-id")); + expect(ids).toEqual( + expect.arrayContaining([geschrieben.team[0]!.assignmentId, SERVERSTAND_ID]), + ); + expect(ids).toHaveLength(2); }); /** diff --git a/apps/web/test/planung-a11y.test.tsx b/apps/web/test/planung-a11y.test.tsx index aa61e392..4f68020d 100644 --- a/apps/web/test/planung-a11y.test.tsx +++ b/apps/web/test/planung-a11y.test.tsx @@ -31,13 +31,15 @@ * „genau EINE main-Landmark im ganzen Dokument" muss rot werden und dabei * `2` gegen `1` melden. */ +import type { PlanningWindow } from "@easytree/contracts"; import { cleanup, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import axe from "axe-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { navigationZuruecksetzen } from "./helpers/navigation-attrappe"; import { netzLoesen, werkbankRendern } from "./helpers/werkbank"; -import { fensterMitEntwurf, sitzungMit } from "./helpers/werkbank-daten"; +import { BAUSTELLE_ID, fensterMitEntwurf, PERSON_ID, sitzungMit } from "./helpers/werkbank-daten"; vi.mock("next/navigation", async () => { const modul = await import("./helpers/navigation-attrappe"); @@ -129,3 +131,105 @@ describe("EYT-141 — Barrierefreiheit der Planungsflaeche", () => { expect((abzeichen.textContent ?? "").trim()).not.toBe(""); }); }); + +// --------------------------------------------------------------------------- +// EYT-158 — die Baustellentag-Karte ist Teil der gepruefen Flaeche +// --------------------------------------------------------------------------- +// `fensterMitEntwurf` traegt keine `worksiteDays`; die Faelle oben sahen die +// neue Karte deshalb nie. Hier ein Fenster mit genau EINER Karte (drei +// Personen, Mittwoch der KW34), der bestehenden Legacy-Zeile vom Dienstag und +// dem offenen Inspector mit Mehrfachauswahl — die integrierte Flaeche, nicht +// eine Komponentendemo. +const KLETTERER_ID = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; +const BODEN_ID = "b2b2b2b2-b2b2-4b2b-8b2b-b2b2b2b2b2b2"; +const TAG_ID = "d4d4d4d4-d4d4-4d4d-8d4d-d4d4d4d4d4d4"; +const REVISION_ID = "e5e5e5e5-e5e5-4e5e-8e5e-e5e5e5e5e5e5"; + +function fensterMitBaustellentag(weekKey: string): PlanningWindow { + const basis = fensterMitEntwurf(weekKey); + const team = [PERSON_ID, KLETTERER_ID, BODEN_ID].map((employeeId, index) => ({ + assignmentId: `c3c3c3c3-c3c3-4c3c-8c3c-c3c3c3c3c3c${index}`, + employeeId, + interval: { startUtc: "2026-08-19T06:00:00.000Z", endUtc: "2026-08-19T16:00:00.000Z" }, + })); + return { + ...basis, + resources: { + employees: [ + ...basis.resources.employees, + { id: KLETTERER_ID, label: "Kai Kletterer", active: true }, + { id: BODEN_ID, label: "Bea Boden", active: true }, + ], + worksites: basis.resources.worksites, + }, + assignments: [ + ...basis.assignments, + ...team.map((mitglied) => ({ + id: mitglied.assignmentId, + employeeId: mitglied.employeeId, + worksiteId: BAUSTELLE_ID, + interval: mitglied.interval, + })), + ], + worksiteDays: [ + { + worksiteDayId: TAG_ID, + configurationId: REVISION_ID, + worksiteId: BAUSTELLE_ID, + localDate: "2026-08-19", + lockVersion: 0, + team, + }, + ], + }; +} + +const AXE_OPTIONEN = { + runOnly: { + type: "tag" as const, + values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"], + }, + rules: { "color-contrast": { enabled: false } }, +}; + +async function werkbankGeladenMit(fenster: (weekKey: string) => PlanningWindow): Promise { + werkbankRendern({ + sitzung: sitzungMit(["planning.read", "planning.write", "costs.read"]), + fenster, + }); + await screen.findByTestId("werkbank-woche-iso"); + await waitFor(() => expect(screen.queryByTestId("planungsfenster-laedt")).toBeNull()); +} + +describe("EYT-158 — Barrierefreiheit der Baustellentag-Karte", () => { + it("hat mit Karte, Legacy-Zeile und offenem Inspector null axe-Verstoesse", async () => { + await werkbankGeladenMit(fensterMitBaustellentag); + // Vakuumschutz: die Karte und die Legacy-Zeile sind wirklich im Baum. + expect(document.querySelectorAll(".einsatzkarte")).toHaveLength(1); + expect(document.querySelectorAll(".legacy-einplanung")).toHaveLength(1); + expect(document.querySelectorAll(".einsatzkarte [data-assignment-id]")).toHaveLength(3); + + const geschlossen = await axe.run(document.body, AXE_OPTIONEN); + expect(geschlossen.violations.map((v) => `${v.id}: ${v.nodes.length}`)).toEqual([]); + + await userEvent.click(screen.getByTestId("werkbank-einsatz-anlegen")); + await screen.findByTestId("einsatzformular"); + expect((screen.getByTestId("feld-employee") as HTMLSelectElement).multiple).toBe(true); + const offen = await axe.run(document.body, AXE_OPTIONEN); + expect(offen.violations.map((v) => `${v.id}: ${v.nodes.length}`)).toEqual([]); + }); + + it("haelt die Ueberschriftenfolge auch in der Rueckfallliste einer unbekannten Zeitzone", async () => { + // Der Server nennt eine Zone, die diese Laufzeit nicht kennt: die Ansicht + // faellt auf die ungeordnete Liste zurueck. Auch dort duerfen die + // Ueberschriften keine Stufe ueberspringen (axe `heading-order`). + await werkbankGeladenMit((weekKey) => ({ + ...fensterMitBaustellentag(weekKey), + timeZone: "Mars/Olympus", + })); + await screen.findByTestId("planungsfenster-liste"); + expect(document.querySelectorAll(".legacy-einplanung")).toHaveLength(1); + const ergebnis = await axe.run(document.body, AXE_OPTIONEN); + expect(ergebnis.violations.map((v) => `${v.id}: ${v.nodes.length}`)).toEqual([]); + }); +}); diff --git a/apps/web/test/werkbank-oberflaechenguards.test.tsx b/apps/web/test/werkbank-oberflaechenguards.test.tsx new file mode 100644 index 00000000..ff15e2eb --- /dev/null +++ b/apps/web/test/werkbank-oberflaechenguards.test.tsx @@ -0,0 +1,202 @@ +/** + * 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 { + // EYT-147 M2: neue Portmethoden. Diese Ansicht ruft sie nicht auf — + // ein Wurf faellt auf, eine stille Leerantwort nicht. + planWorksiteDay: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + updateWorksiteDayTeam: () => { + throw new Error("in dieser Ansicht nicht benutzt"); + }, + 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..b3e46a3e 100644 --- a/apps/web/test/werkbank-serverwahrheit.test.tsx +++ b/apps/web/test/werkbank-serverwahrheit.test.tsx @@ -38,9 +38,11 @@ * * ## Gegenmutationen (Phase 1) * - * 1. `apps/web/components/planning-window-view.tsx:205` — `setNachladen((n) => n + 1)` - * im Erfolgszweig von `speichern` entfernen → „zeigt nach dem Speichern den - * erneut gelesenen Serverstand" geht rot, weil `EINSATZ_FREMD` nie erscheint. + * 1. In `speichern` (`apps/web/components/planning-window-view.tsx`) den + * Readback ueberspringen: vor `gateway.getPlanningWindow` mit `{ ok: true }` + * zurueckkehren und den Schluessel verwerfen → „zeigt nach dem Speichern den + * erneut gelesenen Serverstand" geht rot, weil `EINSATZ_FREMD` nie erscheint + * (seit EYT-158 ersetzt der gepruefte Readback das fruehere `setNachladen`). * 2. In `speichern` (`:188-191`) `weekKey` durch eine Konstante ersetzen → * „schreibt in die Woche, die auf dem Schirm steht" geht rot. * 3. `apps/web/components/planning-window-view.tsx:143-147` — bei `!result.ok` @@ -50,8 +52,12 @@ * Alle drei sind heute nicht ausfuehrbar, weil der parameterlose Einstieg fehlt * und die Tests schon davor rot sind. */ -import { IDEMPOTENCY_HEADER, type CreateAssignmentCommand } from "@easytree/contracts"; -import { cleanup, screen, waitFor, within } from "@testing-library/react"; +import { + IDEMPOTENCY_HEADER, + type PlanWorksiteDayCommand, + type WorksiteDayDto, +} from "@easytree/contracts"; +import { cleanup, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -75,6 +81,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,53 +197,68 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach * sie es auch. */ async function einsatzAnlegen(datum: string): Promise { - await userEvent.selectOptions(await screen.findByTestId("feld-employee"), PERSON_ID); - await userEvent.selectOptions(screen.getByTestId("feld-worksite"), BAUSTELLE_ID); + // 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-worksite"), BAUSTELLE_ID); await userEvent.type(screen.getByTestId("feld-datum"), datum); - await userEvent.type(screen.getByTestId("feld-beginn"), "08:00"); + await userEvent.clear(screen.getByTestId("feld-ende")); await userEvent.type(screen.getByTestId("feld-ende"), "16:00"); + await userEvent.selectOptions(screen.getByTestId("feld-employee"), PERSON_ID); await userEvent.click(screen.getByTestId("einsatz-speichern")); } function werkbankMitSchreibpfad(): { netz: Netzprotokoll; - gesendet: CreateAssignmentCommand[]; + gesendet: PlanWorksiteDayCommand[]; } { - const gesendet: CreateAssignmentCommand[] = []; - let geschrieben = false; + const gesendet: PlanWorksiteDayCommand[] = []; + let geschrieben: WorksiteDayDto | null = null; const netz = werkbankRendern({ sitzung: sitzungMit(RECHTE), - fenster: (weekKey) => - fensterMitEntwurf( + fenster: (weekKey) => { + const window = fensterMitEntwurf( weekKey, - geschrieben - ? [ + geschrieben === null + ? [VORHANDEN] + : [ VORHANDEN, - { - id: EINSATZ_NEU_VOM_SERVER, - startUtc: "2026-08-25T06:00:00.000Z", - endUtc: "2026-08-25T14:00:00.000Z", - }, - // Diese Zuweisung entstand waehrend des Schreibvorgangs an - // anderer Stelle. Sie kann NUR ueber ein erneutes Lesen - // sichtbar werden. + // Nur der Readback kennt diese parallele Einplanung. { id: EINSATZ_FREMD, startUtc: "2026-08-26T06:00:00.000Z", endUtc: "2026-08-26T14:00:00.000Z", }, - ] - : [VORHANDEN], - ), - einsatzAnlegen: (befehl) => { + ], + ); + return geschrieben === null + ? window + : { + ...window, + worksiteDays: [geschrieben], + assignments: [ + ...window.assignments, + ...geschrieben.team.map((m) => ({ + id: m.assignmentId, + employeeId: m.employeeId, + worksiteId: geschrieben!.worksiteId, + interval: m.interval, + })), + ], + }; + }, + baustellentagAnlegen: (befehl) => { gesendet.push(befehl); - geschrieben = true; - return { - id: EINSATZ_NEU_VOM_SERVER, - employeeId: befehl.employeeId, + geschrieben = { + worksiteDayId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + configurationId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", worksiteId: befehl.worksiteId, - interval: befehl.interval, + localDate: befehl.localDate, + lockVersion: 0, + team: befehl.team.map((m) => ({ ...m, assignmentId: EINSATZ_NEU_VOM_SERVER })), }; + return geschrieben; }, }); return { netz, gesendet }; @@ -249,7 +276,7 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach await waitFor(() => expect(gesendet).toHaveLength(1)); expect(gesendet[0]?.weekKey).toBe("2026-W35"); - expect(gesendet[0]?.interval.startUtc.startsWith("2026-08-25")).toBe(true); + expect(gesendet[0]?.team[0]?.interval.startUtc.startsWith("2026-08-25")).toBe(true); }); it("schickt den Schreibaufruf mit Idempotenzschluessel ueber den Vertragsclient", async () => { @@ -258,8 +285,8 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach await einsatzAnlegen("2026-08-19"); - await waitFor(() => expect(netz.aufrufeAn("/api/v1/planung/einsaetze")).toHaveLength(1)); - const schreiben = netz.aufrufeAn("/api/v1/planung/einsaetze")[0]; + await waitFor(() => expect(netz.aufrufeAn("/api/v1/planung/baustellentage")).toHaveLength(1)); + const schreiben = netz.aufrufeAn("/api/v1/planung/baustellentage")[0]; expect(schreiben?.methode).toBe("POST"); expect(schreiben?.kopf[IDEMPOTENCY_HEADER] ?? "").not.toBe(""); }); @@ -284,7 +311,7 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach // Und die Reihenfolge stimmt: erst schreiben, dann lesen. const reihenfolge = netz.aufrufe.map((a) => `${a.methode} ${a.pfad}`); - const schreibIndex = reihenfolge.indexOf("POST /api/v1/planung/einsaetze"); + const schreibIndex = reihenfolge.indexOf("POST /api/v1/planung/baustellentage"); const leseIndexDanach = reihenfolge.indexOf("GET /api/v1/planung/fenster", schreibIndex + 1); expect(schreibIndex).toBeGreaterThanOrEqual(0); expect(leseIndexDanach).toBeGreaterThan(schreibIndex); @@ -299,11 +326,13 @@ describe("REQ-004 / AC-006, AC-007 — Schreiben in die angesehene Woche, danach expect(screen.getByTestId("planungsfenster-liste").textContent ?? "").not.toBe(""), ); - const schluessel = netz.aufrufeAn("/api/v1/planung/einsaetze")[0]?.kopf[IDEMPOTENCY_HEADER]; - const liste = within(screen.getByTestId("planungsfenster-liste")); - const ids = liste - .getAllByRole("listitem") - .map((zeile) => zeile.getAttribute("data-assignment-id")); + const schluessel = netz.aufrufeAn("/api/v1/planung/baustellentage")[0]?.kopf[ + IDEMPOTENCY_HEADER + ]; + expect(schluessel).toBeTruthy(); + const ids = [ + ...screen.getByTestId("planungsfenster-liste").querySelectorAll("[data-assignment-id]"), + ].map((zeile) => zeile.getAttribute("data-assignment-id")); expect(ids).not.toContain(schluessel); expect(ids.every((id) => id !== null && id !== "")).toBe(true); }); 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/apps/web/test/worksite-day-create.test.tsx b/apps/web/test/worksite-day-create.test.tsx new file mode 100644 index 00000000..b9aad21c --- /dev/null +++ b/apps/web/test/worksite-day-create.test.tsx @@ -0,0 +1,356 @@ +import type { PlanningGateway, PlanningWindow, WorksiteDayDto } from "@easytree/contracts"; +import { act, cleanup, render, screen, waitFor } 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"; + +afterEach(cleanup); +const employees = [1, 2, 3].map((n) => ({ + id: `22222222-2222-4222-8222-22222222222${n}`, + label: `Person ${n}`, + active: true, +})); +const site = "33333333-3333-4333-8333-333333333333"; +const empty: PlanningWindow = { + weekKey: "2028-W02", + timeZone: "Europe/Berlin", + assignments: [], + worksiteDays: [], + sourceVersion: null, + publishedVersionId: null, + resources: { employees, worksites: [{ id: site, label: "Baustelle Nord", active: true }] }, +}; +const day: WorksiteDayDto = { + worksiteDayId: "44444444-4444-4444-8444-444444444444", + configurationId: "55555555-5555-4555-8555-555555555555", + worksiteId: site, + localDate: "2028-01-10", + lockVersion: 0, + team: employees.map((e, n) => ({ + employeeId: e.id, + assignmentId: `66666666-6666-4666-8666-66666666666${n}`, + interval: { startUtc: "2028-01-10T07:00:00.000Z", endUtc: "2028-01-10T17:00:00.000Z" }, + })), +}; +const saved: PlanningWindow = { + ...empty, + worksiteDays: [day], + assignments: day.team.map((m) => ({ + id: m.assignmentId, + employeeId: m.employeeId, + worksiteId: site, + interval: m.interval, + })), + sourceVersion: { id: "77777777-7777-4777-8777-777777777777", state: "draft" }, +}; + +function setup(initial = empty) { + const getPlanningWindow = vi + .fn() + .mockResolvedValue({ ok: true, value: initial }); + const planWorksiteDay = vi + .fn() + .mockResolvedValue({ ok: true, value: day }); + const createAssignment = vi + .fn() + .mockResolvedValue({ ok: false, failure: "REJECTED", problem: null }); + const unused = () => { + throw new Error("Nicht Teil dieses Flows"); + }; + const gateway: PlanningGateway = { + getPlanningWindow, + planWorksiteDay, + createAssignment, + updateWorksiteDayTeam: unused, + validateDraft: unused, + publishPlan: unused, + }; + render( + + + , + ); + return { getPlanningWindow, planWorksiteDay, createAssignment }; +} +async function open() { + await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen")); + return screen.getByTestId("einsatzformular"); +} +async function fill() { + await userEvent.selectOptions(screen.getByTestId("feld-worksite"), site); + await userEvent.type(screen.getByTestId("feld-datum"), day.localDate); + await userEvent.selectOptions( + screen.getByTestId("feld-employee"), + employees.map((e) => e.id), + ); +} + +describe("EYT-158 worksite-first Create", () => { + it("rendert genau eine primäre Baustellentag-Karte für drei Einplanungen", async () => { + setup(saved); + await screen.findByTestId("planungsfenster-liste"); + // RED-Phase vor EYT-158: drei Assignment-Karten statt einer Tageskarte. + const cards = document.querySelectorAll(".einsatzkarte"); + expect(cards).toHaveLength(1); + const card = cards[0]!; + expect(card.getAttribute("data-worksite-day-id")).toBe(day.worksiteDayId); + expect(card.getAttribute("data-configuration-id")).toBe(day.configurationId); + expect(card.textContent).toContain("Baustelle Nord"); + // Deutsches Datum, nicht der ISO-String; maschinenlesbar bleibt `dateTime`. + expect(card.textContent).toContain("10.01.2028"); + expect(card.querySelector("time")?.getAttribute("dateTime")).toBe("2028-01-10"); + expect(card.textContent).toContain("08:00–18:00"); + expect(card.textContent).toContain("Einsatzteam"); + expect(card.querySelectorAll("[data-assignment-id]")).toHaveLength(3); + for (const person of employees) expect(card.textContent).toContain(person.label); + expect(document.querySelectorAll("[data-assignment-id]")).toHaveLength(3); + expect(card.closest("[data-tag]")?.getAttribute("data-tag")).toBe(day.localDate); + }); + + it("erhält Legacy-Einplanungen getrennt ohne erfundene Tagesidentität", async () => { + const legacy = { ...saved.assignments[0]!, id: "88888888-8888-4888-8888-888888888888" }; + setup({ ...saved, assignments: [...saved.assignments, legacy] }); + await screen.findByTestId("planungsfenster-liste"); + expect(document.querySelectorAll(".einsatzkarte")).toHaveLength(1); + expect( + document + .querySelector(`[data-assignment-id="${legacy.id}"]`) + ?.closest("[data-worksite-day-id]"), + ).toBeNull(); + expect(document.querySelectorAll("[data-assignment-id]")).toHaveLength(4); + expect(screen.getByText("Einplanungen ohne Baustellentag")).toBeTruthy(); + }); + + it("beginnt mit Baustelle, Datum, editierbarer Arbeitszeit und danach dem Einsatzteam", async () => { + setup(); + const form = await open(); + // RED-Phase vor EYT-158: die einzelne Person stand zuerst, die Zeiten waren leer. + expect( + [...form.querySelectorAll("select,input")].map((e) => e.getAttribute("data-testid")), + ).toEqual(["feld-worksite", "feld-datum", "feld-beginn", "feld-ende", "feld-employee"]); + expect(document.activeElement).toBe(screen.getByTestId("feld-worksite")); + expect((screen.getByTestId("feld-beginn") as HTMLInputElement).value).toBe("08:00"); + expect((screen.getByTestId("feld-ende") as HTMLInputElement).value).toBe("18:00"); + expect((screen.getByTestId("feld-employee") as HTMLSelectElement).multiple).toBe(true); + await userEvent.clear(screen.getByTestId("feld-beginn")); + await userEvent.type(screen.getByTestId("feld-beginn"), "09:00"); + expect((screen.getByTestId("feld-beginn") as HTMLInputElement).value).toBe("09:00"); + }); + + it("sendet drei Personen in genau einem WorksiteDay-Command und bestätigt erst den Readback", async () => { + const g = setup(); + await open(); + await fill(); + let finish!: (value: Awaited>) => void; + g.getPlanningWindow.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + await waitFor(() => expect(g.planWorksiteDay).toHaveBeenCalledTimes(1)); + expect(g.planWorksiteDay.mock.calls[0]?.[0]).toEqual({ + weekKey: empty.weekKey, + worksiteId: site, + localDate: day.localDate, + team: day.team.map(({ employeeId, interval }) => ({ employeeId, interval })), + }); + expect(g.createAssignment).not.toHaveBeenCalled(); + expect(screen.queryByTestId("einsatzformular-meldung")).toBeNull(); + finish({ ok: true, value: saved }); + await waitFor(() => + expect(screen.getByTestId("einsatzformular-meldung").getAttribute("data-state")).toBe( + "erfolg", + ), + ); + }); + + it.each([ + "UNAUTHENTICATED", + "FORBIDDEN", + "REJECTED", + "STALE_VERSION", + "UNAVAILABLE", + "CONTRACT_VIOLATION", + ] as const)("zeigt %s ohne Erfolg oder erfundene Karte", async (failure) => { + const g = setup(); + g.planWorksiteDay.mockResolvedValue({ ok: false, failure, problem: null }); + await open(); + await fill(); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + const message = await screen.findByTestId("einsatzformular-meldung"); + expect(message.getAttribute("data-state")).toBe("fehler"); + expect(message.getAttribute("role")).toBe("alert"); + expect(document.querySelectorAll("[data-worksite-day-id]")).toHaveLength(0); + expect(g.getPlanningWindow).toHaveBeenCalledTimes(1); + }); + + it("meldet fehlenden Readback statt Erfolg und behält beim Retry denselben Schlüssel", async () => { + const g = setup(); + await open(); + await fill(); + // Write erfolgreich, Read lässt den Tag weg: keine Erfolgsmeldung. + await userEvent.click(screen.getByTestId("einsatz-speichern")); + expect((await screen.findByTestId("einsatzformular-meldung")).getAttribute("data-state")).toBe( + "fehler", + ); + g.getPlanningWindow.mockResolvedValue({ ok: true, value: saved }); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + await waitFor(() => + expect(screen.getByTestId("einsatzformular-meldung").getAttribute("data-state")).toBe( + "erfolg", + ), + ); + expect(g.planWorksiteDay).toHaveBeenCalledTimes(2); + expect(g.planWorksiteDay.mock.calls[0]?.[1]).toEqual(g.planWorksiteDay.mock.calls[1]?.[1]); + }); + + it("zeigt bei fehlgeschlagenem Readback keine POST-basierte Erfolgskarte", async () => { + const g = setup(); + await open(); + await fill(); + g.getPlanningWindow.mockResolvedValue({ ok: false, failure: "UNAVAILABLE", problem: null }); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + expect((await screen.findByTestId("einsatzformular-meldung")).getAttribute("data-state")).toBe( + "fehler", + ); + expect(document.querySelectorAll("[data-worksite-day-id]")).toHaveLength(0); + }); +}); + +const nichtTeilDesFlows = () => { + throw new Error("Nicht Teil dieses Flows"); +}; + +describe("EYT-158 Readback-Wahrheit", () => { + it.each([ + [ + "anderer Konfigurationsrevision", + (d: WorksiteDayDto): WorksiteDayDto => ({ + ...d, + configurationId: "99999999-9999-4999-8999-999999999999", + }), + ], + [ + "fehlendem Teammitglied", + (d: WorksiteDayDto): WorksiteDayDto => ({ ...d, team: d.team.slice(0, 2) }), + ], + [ + "anderer lockVersion", + (d: WorksiteDayDto): WorksiteDayDto => ({ ...d, lockVersion: d.lockVersion + 1 }), + ], + ])( + "meldet bei einem Readback mit %s keinen Erfolg und behaelt den Vorgangsschluessel", + async (_name, abweichend) => { + const g = setup(); + await open(); + await fill(); + // Der Server bestaetigt den Write, der Readback widerspricht ihm in + // GENAU einem Merkmal. Ein Vergleich, der nur die Tagesidentitaet + // prueft, liesse jede dieser Abweichungen als Erfolg durch. + g.getPlanningWindow.mockResolvedValue({ + ok: true, + value: { ...saved, worksiteDays: [abweichend(day)] }, + }); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + const message = await screen.findByTestId("einsatzformular-meldung"); + expect(message.getAttribute("data-state")).toBe("fehler"); + expect(message.textContent).toMatch(/stimmt nicht überein/); + // Kein Erfolgszustand irgendwo im Formular — der Leerzustand der Woche + // traegt selbst `role="status"`, deshalb gezielt auf die Meldung. + expect( + document.querySelector('[data-testid="einsatzformular-meldung"][data-state="erfolg"]'), + ).toBeNull(); + + // Der Vorgang ist NICHT abgeschlossen: der Wiederholungsversuch traegt + // denselben Schluessel und bleibt damit derselbe Vorgang. + await userEvent.click(screen.getByTestId("einsatz-speichern")); + await waitFor(() => expect(g.planWorksiteDay).toHaveBeenCalledTimes(2)); + expect(g.planWorksiteDay.mock.calls[0]?.[1]).toEqual(g.planWorksiteDay.mock.calls[1]?.[1]); + }, + ); + + it("verwirft den Readback der alten Woche, wenn waehrend des Speicherns die Woche wechselt", async () => { + const naechste: PlanningWindow = { ...empty, weekKey: "2028-W03" }; + const getPlanningWindow = vi.fn(); + let readbackAufloesen!: ( + wert: Awaited>, + ) => void; + getPlanningWindow.mockImplementation(({ weekKey }) => { + if (weekKey === naechste.weekKey) return Promise.resolve({ ok: true, value: naechste }); + // Erster Aufruf fuer W02: das Laden. Zweiter: der Readback nach dem + // Write — er bleibt offen, bis der Test ihn aufloest. + const bisher = getPlanningWindow.mock.calls.filter(([i]) => i.weekKey === empty.weekKey); + return bisher.length === 1 + ? Promise.resolve({ ok: true, value: empty }) + : new Promise((resolve) => { + readbackAufloesen = resolve; + }); + }); + const gateway: PlanningGateway = { + getPlanningWindow, + planWorksiteDay: vi + .fn() + .mockResolvedValue({ ok: true, value: day }), + createAssignment: nichtTeilDesFlows, + updateWorksiteDayTeam: nichtTeilDesFlows, + validateDraft: nichtTeilDesFlows, + publishPlan: nichtTeilDesFlows, + }; + const { rerender } = render( + + + , + ); + await open(); + await fill(); + await userEvent.click(screen.getByTestId("einsatz-speichern")); + await waitFor(() => expect(getPlanningWindow).toHaveBeenCalledTimes(2)); + + // Wochenwechsel, waehrend der Readback der alten Woche noch offen ist. + rerender( + + + , + ); + await waitFor(() => expect(getPlanningWindow).toHaveBeenCalledTimes(3)); + await screen.findByTestId("planungsfenster-leer"); + + // Jetzt kommt der Readback der ALTEN Woche an. Der Write war erfolgreich — + // aber die Karte gehoert nach W02 und darf W03 nicht ueberschreiben. Das + // Formular der alten Woche ist mit dem Wochenwechsel ausgehaengt; die + // einzige beobachtbare Wirkung eines fehlenden Riegels waere die fremde + // Karte im Baum. + await act(async () => { + readbackAufloesen({ ok: true, value: saved }); + await Promise.resolve(); + }); + expect(getPlanningWindow).toHaveBeenCalledTimes(3); + expect(document.querySelectorAll("[data-worksite-day-id]")).toHaveLength(0); + expect(screen.getByTestId("planungsfenster-leer")).toBeTruthy(); + }); +}); + +describe("EYT-158 Leerzustand des Formulars", () => { + it.each([ + [ + "ohne aktive Mitarbeitende", + { employees: [], worksites: empty.resources.worksites }, + /keine aktiven Mitarbeitenden/, + ], + ["ohne aktive Baustellen", { employees, worksites: [] }, /keine aktiven Baustellen/], + ])("erklaert den Leerzustand %s statt ein Formular zu zeigen", async (_name, resources, text) => { + setup({ ...empty, resources }); + await userEvent.click(await screen.findByTestId("werkbank-einsatz-anlegen")); + const leer = await screen.findByTestId("einsatzformular-leer"); + expect(leer.textContent).toMatch(text); + expect(screen.queryByTestId("einsatzformular")).toBeNull(); + // Benannter Bereich: der Erklaertext ist als Region anspringbar, nicht + // nur als loser Absatz — dieselbe Zusicherung wie beim Formular selbst. + const labelledBy = leer.getAttribute("aria-labelledby") ?? ""; + expect(labelledBy).not.toBe(""); + expect(document.getElementById(labelledBy)?.textContent).toContain("Baustellentag planen"); + }); +}); 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/docs/evidence/2026-09-06-eyt-158-worksite-first/12-werkbank-inspector-1440.png b/docs/evidence/2026-09-06-eyt-158-worksite-first/12-werkbank-inspector-1440.png new file mode 100644 index 00000000..a7ef7ece Binary files /dev/null and b/docs/evidence/2026-09-06-eyt-158-worksite-first/12-werkbank-inspector-1440.png differ diff --git a/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1440.png b/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1440.png new file mode 100644 index 00000000..de52fdec Binary files /dev/null and b/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1440.png differ diff --git a/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1920.png b/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1920.png new file mode 100644 index 00000000..68c4b7eb Binary files /dev/null and b/docs/evidence/2026-09-06-eyt-158-worksite-first/13-werkbank-baustellentag-serverbestaetigt-1920.png differ diff --git a/docs/evidence/2026-09-06-eyt-158-worksite-first/README.md b/docs/evidence/2026-09-06-eyt-158-worksite-first/README.md new file mode 100644 index 00000000..5f5ef6fd --- /dev/null +++ b/docs/evidence/2026-09-06-eyt-158-worksite-first/README.md @@ -0,0 +1,101 @@ +# EYT-158 — Baustellentag worksite-first: Evidenzpaket + +> **Stand:** 06.09.2026, Europe/Berlin +> **Basis:** `origin/master` = `f8e96e4ceb4f00ae5f5ac777c6eb47aa8f766d6f` +> **Branch:** `feat/eyt-147-dispositionswerkbank-slice-1` (PR #101, `DO_NOT_MERGE`), Ausgangs-Head `c3994d1c7a8372114d6989856299c2ccda58c42a` +> **Evidenzstufe dieses Pakets:** lokale Läufe auf dem Entwicklungsrechner (colima, M-Serie). +> Der ausgeführte CI-Lauf am neuen PR-Head ist die höhere Stufe und wird nach dem Push nachgetragen. +> **Ausführender:** Claude Code als temporärer Executor (Übernahme des Codex-Arbeitsstands, Handoff-Sicherung `/tmp/easytree-eyt158-codex-handoff*.patch`). + +## Was der Slice liefert (Jira EYT-158) + +`Planung öffnen → „Baustellentag anlegen" → Baustelle → lokaler Tag → Arbeitszeit (Vorgabe 08:00–18:00, +editierbar) → drei interne Mitarbeitende als EIN Einsatzteam → EIN Command (`POST /planung/baustellentage`) +→ bestätigter Server-Readback → GENAU EINE Baustellentag-Karte mit dem Team darunter → Reload und +zweiter Browserkontext zeigen dieselben Server-Ids.` + +Keine Migration, kein Vertragsbruch (`worksiteDays` war in `PlanningWindowSchema` bereits optional +vorgesehen, EYT-151), keine neue Fachregel. `packages/ui/**` unberührt. + +## Bilder (realer Lauf der Auth-Reise, echte GoTrue-Identität, echte API, PostgreSQL) + +| Datei | Zeigt | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `12-werkbank-inspector-1440.png` | Inspector worksite-first: Baustelle zuerst (fokussiert), Vorgabe 08:00–18:00, Mehrfachauswahl | +| `13-werkbank-baustellentag-serverbestaetigt-1440.png` | Genau eine Karte am Di 18.08. mit drei Personen darunter; Erfolgsmeldung erst nach Readback | +| `13-werkbank-baustellentag-serverbestaetigt-1920.png` | Dieselbe Planung bei 1920 px | + +Human-PO-Visual-Gate („Würden wir genau diese Planung Arboscus heute zeigen?") ist **nicht** durch +dieses Paket beantwortet — das kann nur der Product Owner. Sichtbare Punkte für diese Entscheidung: +das Datum in der Karte ist jetzt deutsch formatiert („Di., 18.08.2026"); der Chromium-Testbrowser +zeigt time-Inputs in `en-US` (AM/PM) — ein Browser-Locale-Effekt, kein Produktverhalten. + +## Lokale Läufe (Befehle und gemessene Ergebnisse) + +| Gate | Befehl | Ergebnis | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Lint + Typen | `pnpm exec turbo run lint typecheck --force` | `16 successful, 16 total`, `Cached: 0` | +| Unit-Gate | `EASYTREE_TEST_DB_URL= … pnpm exec turbo run test --force --continue --concurrency=1 --env-mode=loose` | `10 successful, 10 total`, `Cached: 0` — Web 42 Dateien / 499 Tests, API 79 Dateien / 829 bestanden, 99 `mode=local`-Skips (siehe DB-Gates) | +| DB-Gates (CI-Reihenfolge) | `supabase db reset`, dann je Suite EIN `vitest run` mit den `db-gates`-Umgebungen aus `ci.yml` | alle 12 Zeilen `mode=required … skipped=0`: tenant-isolation 7, planning-invariants 6, **planning-write 21/21**, planning-publish 15, cost-access 6, rate-timestamp 2, rate-http 4, rate-succession 8, **planning-published-reads 9/9**, cost-snapshot 11, snapshot-http 7, snapshot-immutability 3 | +| Build | `env -u EASYTREE_API_PROXY_TARGET pnpm exec turbo run build --force` | `6 successful`, `Cached: 0` | +| Format | `git ls-files -z \| xargs -0 pnpm exec prettier --ignore-unknown --check` (+ dieselbe Prüfung über die neuen Dateien) | „All matched files use Prettier code style!" (beide) | +| `git diff --check` | | exit 0 | +| auth-journey (echte Identität) | `pnpm exec playwright test -c e2e/auth-journey/config.ts` nach `supabase db reset` (siehe Abschnitt _Journeys_) | siehe _Journeys_ | +| read-through (lokal emuliert) | Phasen aus `scripts/read-through-harness.sh`; Abweichungen: `supabase start` übersprungen (Stack lief), `playwright install` ohne `--with-deps` | siehe _Journeys_ | + +Zwei Messfallen dieses Tages, damit sie niemand als Regression liest: (1) Mehrere DB-Suiten in EINEM +`vitest run` laufen in parallelen Workern gegen dieselbe Datenbank und färben die Concurrency-Fälle +rot (`0 Gewinner`); CI fährt je Suite einen Schritt. (2) Veröffentlichte Wochen sind unlöschbar — +wiederholte `planning-write`-Läufe ohne `db reset` lassen die vier EYT-92/107-Publish-Fixtures an +`assignments_no_published_overlap` scheitern. Beides verschwand mit frischem Reset. + +## Journeys (Abschlusslauf auf dem endgültigen Code, 06.09.2026 03:11 Europe/Berlin) + +| Reise | Ergebnis | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| auth-journey (echte GoTrue-Identität, `dist/main.js`) | **5 passed** (Kernreise 23,4 s; Schritt 9c1b: Baustellentag worksite-first mit drei Personen, Karte, Erfolgsmeldung erst nach Readback, `pruefeBarrierefreiheit` bei 1440/1920/720 px inkl. Tastatur ohne Falle und sichtbarem Fokus, Reload, zweiter Browserkontext mit eigener Anmeldung); `globalTeardown` und zweimaliger Teardown `restzeilen=0 … trigger=3` | +| read-through (Browser → Route Handler → NestJS → RLS → PG) | Phase 12c **2 passed** · 12d `Fixture OK: 3 Alpha-Zuweisungen, 3 Alpha-Mitarbeitende, 1 Beta` · Phase 13 **7 passed** (inkl. Responsive/axe/Fokusreihenfolge bei 1440 und 375 px) · 13b Schreibpfad **7 passed** (Drei-Personen-Tag, Duplikat-409, Personenkonflikt-409 atomar, ungültiges Intervall, Reload + zweiter Kontext) · 15 **1 passed**; `Read-Through-Harness OK`, exit 0 | + +Ein früherer Abschlusslauf der auth-journey scheiterte nach 2 ms an einem fehlenden +Playwright-Browser (`chrome-headless-shell` Revision 1228 aus dem Cache verschwunden — Umgebung, +zwei Playwright-Versionen im Workspace: Root 1.52.0/1169, Web 1.61.1/1228); nach +`playwright install chromium` derselbe Build, 5 passed. Kein Produktsignal. + +## Gegenmutationen (ausgeführt, gemessen, zurückgenommen — Datei-Hash vor/nach identisch) + +| # | Mutation | Gemessen rot | +| --- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **employee-first**: Team-Block im Formular VOR den Baustellen-Block gezogen | 2 rot — „beginnt mit Baustelle, Datum, editierbarer Arbeitszeit …", „setzt beim Oeffnen den Fokus in das erste Bedienelement" | +| 2 | **card-per-assignment**: `WorksiteDayCard` je Teammitglied statt je Tag | 2 rot — „rendert genau eine primäre Baustellentag-Karte …", „erhält Legacy-Einplanungen getrennt …" | +| 3 | **success-without-readback**: in `speichern` vor dem Readback mit `{ ok: true }` zurückkehren | 7 rot — u. a. „bestätigt Erfolg erst nach serverseitigem Read-through", alle drei Readback-Abweichungsfälle, „fehlgeschlagener Readback" | +| 4 | **zwei Statements statt einem Snapshot** (RED-Phase des neuen Wächters `planning-window-single-snapshot`) | 1 rot — „expected […(2)] to have a length of 1" | +| 5 | **Wochenwechsel während des Readbacks** (RED-Phase vor dem Riegel `aktuelleWoche`) | 1 rot — fremde Karte im Baum der neuen Woche | +| 6 | **h2 → h4 in der Rückfallliste** (RED-Phase vor der Überschriftenebene) | 1 rot — axe `heading-order: 1` | +| 7 | **Folgedraft über `createAssignment`** (RED-Phase vor der gemeinsamen Baseline-Kopie) | 1 rot — „expected [] to have a length of 1" (`worksiteDays` des Folgedrafts leer) | + +## Adversariales Review (Workflow, 5 Linsen, je Befund zwei Widerleger) — Adjudikation + +| Befund | Einordnung | Maßnahme | +| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| e2e-Reisen fuhren noch den alten Einzel-Einsatz-Pfad (read-through, auth-journey, staging) | CONFIRMED_BLOCKER | alle drei Specs auf worksite-first umgestellt; Harness-Seed +2, Reise-Fixture +1 Mitarbeitende; Teardown um `worksite_days` erweitert | +| Zwei READ-COMMITTED-Statements für Zuweisungen und Tage → Vertragsverletzung → 500 | CONFIRMED_IMPORTANT | ein Statement (`planstandOf`, FULL OUTER JOIN), statischer Wächter | +| Folgedraft-Kopie nur im `planWorksiteDay`-Pfad | CONFIRMED_IMPORTANT | Baseline-Kopie (Konfigurationen + Zuweisungen mit Umhängung) für beide Pfade geteilt; Integrationstest | +| Readback der alten Woche überschreibt die neue nach Wochenwechsel | CONFIRMED_IMPORTANT | Riegel `aktuelleWoche` in `speichern`; Unit-Test | +| Readback-Abweichung nie geprüft (Mocks identisch) | CONFIRMED_IMPORTANT | drei Abweichungsfälle (Revision, Team, lockVersion) als Tests | +| Rückfallliste springt h2 → h4 | CONFIRMED_MINOR | Überschriftenebene als Prop (`h3` in der Rückfallliste); axe-Test | +| Hilfetext nur Maus-Modifier; Team-Liste doppelt benannt; leere Team-Liste; ISO-Datum; gleichnamige Regionen je Tag; Leerzustand ohne Namen | CONFIRMED_MINOR | Text mit Tastaturfolge, `aria-labelledby`, „Noch kein Einsatzteam", deutsches Datum, Region-Label entfernt, `aria-labelledby` am Leerzustand | +| Legacy-Zeilen ohne CSS | CONFIRMED_MINOR | `.legacy-einplanung`, `.wochenraster__legacy`, `.einsatzkarte__team` gestylt | +| Kein a11y-Wächter rendert die Karte | CONFIRMED_MINOR | `planung-a11y.test.tsx`: Karte + Legacy-Zeile + offener Inspector, axe best-practice | +| Mitarbeiter-Fixture ohne `on conflict` | CONFIRMED_MINOR | idempotenter Insert | +| Idempotenzschlüssel bleibt nach STALE_VERSION | CONFIRMED_MINOR | keine Änderung — Meldung verweist auf Neuladen; ein Wegwerfen erzeugte beim Retry ein Duplikat, das der Server ohnehin ablehnt | +| Mandantenzusicherung auf `worksiteDays` „kann nicht rot werden" | CONFIRMED_MINOR | keine Änderung — der explizite `org_id`-Filter ist Hosenträger zur RLS; eine Mutation daran bleibt durch RLS unsichtbar | +| Exclusion-Constraint macht Folgedraft mit kopierten Intervallen unveröffentlichbar | PRE_EXISTING / OUT_OF_SCOPE | seit EYT-92/109 (Kopie im Einzel-Einsatz-Pfad); Publish-Semantik eines Folgedrafts ist eine PO-Frage → `HUMAN_INPUT_REQUIRED` gemeldet | +| Lock-Reihenfolge `planWorksiteDay` vs `createAssignment` (Deadlock → 40P01/500) | PRE_EXISTING / OUT_OF_SCOPE | Reihenfolge stammt aus EYT-152 (`c3994d1`); kanonische Sperrreihenfolge ist eine Concurrency-Entscheidung → gemeldet | +| „keine erfundene Karte" vakuos | REFUTED (2 Widerleger) | — | + +## Nicht in diesem Paket + +- Die **Staging-Reise** (`e2e/staging/journey.pwtest.ts`) ist auf den neuen Pfad umgestellt, aber **nicht + ausgeführt**: Staging fährt noch das alte Image, ein Deploy ist nicht autorisiert. +- Der **Transaction-Pooler-Gate** (`tenant-pooling`) lief lokal nur `mode=local` (3 Fälle); die + `required`-Messung liefert `db-gates`. diff --git a/packages/contracts/openapi/v1.json b/packages/contracts/openapi/v1.json index 7acb42d0..3cd742a4 100644 --- a/packages/contracts/openapi/v1.json +++ b/packages/contracts/openapi/v1.json @@ -313,6 +313,172 @@ } } }, + "/planung/baustellentage": { + "post": { + "operationId": "planWorksiteDay", + "summary": "Baustellentag anlegen", + "description": "Legt den Baustellentag fuer eine Baustelle an einem expliziten lokalen Kalendertag an und besetzt ihn. Adressiert wird Baustelle plus Tag, nicht eine Id: die stabile worksiteDayId entsteht erst hier. Der Server loest sie auf, haengt sie an die Konfiguration des Wochenentwurfs und antwortet mit dem angelegten Tag einschliesslich lockVersion und der vergebenen assignmentIds. Neu gegenueber den uebrigen Planungsrouten sind die problem.type-Werte urn:easytree:planning:duplicate-worksite-day und urn:easytree:planning:interval-outside-day.", + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "Mandantengebundener Idempotenzschluessel. Ein Wiederholungsaufruf liefert dieselbe Antwort, ohne den Effekt zu verdoppeln.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._~-]+$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanWorksiteDayCommand" + } + } + } + }, + "responses": { + "201": { + "description": "Angelegter Baustellentag", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorksiteDayDto" + } + } + } + }, + "400": { + "description": "Ungueltige Anfrage", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "401": { + "description": "Nicht angemeldet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "403": { + "description": "Nicht berechtigt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "409": { + "description": "Konflikt oder veralteter Planstand. Moegliche problem.type-Werte dieser Route: urn:easytree:planning:duplicate-worksite-day, urn:easytree:planning:interval-outside-day. Zusaetzlich gelten die allgemeinen Planungstypen der uebrigen Schreibrouten unveraendert (Idempotenzschluessel wiederverwendet, ueberlappende Zuweisung, ausserhalb der Woche, nicht auswaehlbare Ressource) - sie werden hier wiederverwendet und nicht neu erfunden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + } + } + } + }, + "/planung/baustellentage/team": { + "post": { + "operationId": "updateWorksiteDayTeam", + "summary": "Tagesbesetzung eines Baustellentags ersetzen", + "description": "Ersetzt die Besetzung eines BESTEHENDEN Baustellentags. Adressiert wird die stabile worksiteDayId - nicht die configurationId, denn die Revision ist durch weekKey eindeutig (hoechstens ein Entwurf je Woche), und nicht der localDate, denn der haengt unveraenderlich an der Identitaet. expectedLockVersion ist die Stale-Erkennung gegen die Tagesidentitaet (lock_version wird beim Kopieren in den Folgedraft uebernommen); weicht sie vom Server ab, antwortet er mit urn:easytree:planning:stale-worksite-day, statt fremde Aenderungen zu ueberschreiben. Kennt er die Id nicht, mit urn:easytree:planning:worksite-day-not-found. Die Antwort traegt die fortgeschriebene lockVersion, damit die naechste Aenderung ohne Zwischenladen moeglich ist.", + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "Mandantengebundener Idempotenzschluessel. Ein Wiederholungsaufruf liefert dieselbe Antwort, ohne den Effekt zu verdoppeln.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._~-]+$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorksiteDayTeamCommand" + } + } + } + }, + "responses": { + "200": { + "description": "Baustellentag mit ersetzter Besetzung", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorksiteDayDto" + } + } + } + }, + "400": { + "description": "Ungueltige Anfrage", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "401": { + "description": "Nicht angemeldet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "403": { + "description": "Nicht berechtigt", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + }, + "409": { + "description": "Konflikt oder veralteter Planstand. Moegliche problem.type-Werte dieser Route: urn:easytree:planning:stale-worksite-day, urn:easytree:planning:worksite-day-not-found, urn:easytree:planning:interval-outside-day. Zusaetzlich gelten die allgemeinen Planungstypen der uebrigen Schreibrouten unveraendert (Idempotenzschluessel wiederverwendet, ueberlappende Zuweisung, ausserhalb der Woche, nicht auswaehlbare Ressource) - sie werden hier wiederverwendet und nicht neu erfunden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDocument" + } + } + } + } + } + } + }, "/einsatz/plan": { "get": { "operationId": "getMySchedule", @@ -1715,6 +1881,97 @@ "worksites" ], "additionalProperties": false + }, + "worksiteDays": { + "type": "array", + "items": { + "type": "object", + "properties": { + "worksiteDayId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "configurationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "worksiteId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "localDate": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Lokaler Kalendertag im Format 2026-08-03 — ohne Uhrzeit, ohne Zone" + }, + "lockVersion": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "team": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assignmentId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "assignmentId", + "employeeId", + "interval" + ], + "additionalProperties": false + } + } + }, + "required": [ + "worksiteDayId", + "configurationId", + "worksiteId", + "localDate", + "lockVersion", + "team" + ], + "additionalProperties": false + } } }, "required": [ @@ -1935,6 +2192,309 @@ ], "additionalProperties": false }, + "WorksiteDayTeamEntry": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "employeeId", + "interval" + ], + "additionalProperties": false + }, + "WorksiteDayTeamMember": { + "type": "object", + "properties": { + "assignmentId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "assignmentId", + "employeeId", + "interval" + ], + "additionalProperties": false + }, + "WorksiteDayDto": { + "type": "object", + "properties": { + "worksiteDayId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "configurationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "worksiteId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "localDate": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Lokaler Kalendertag im Format 2026-08-03 — ohne Uhrzeit, ohne Zone" + }, + "lockVersion": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "team": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assignmentId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "assignmentId", + "employeeId", + "interval" + ], + "additionalProperties": false + } + } + }, + "required": [ + "worksiteDayId", + "configurationId", + "worksiteId", + "localDate", + "lockVersion", + "team" + ], + "additionalProperties": false + }, + "PlanWorksiteDayCommand": { + "type": "object", + "properties": { + "weekKey": { + "type": "string", + "pattern": "^\\d{4}-W(0[1-9]|[1-4]\\d|5[0-3])$", + "description": "ISO-Woche im Format 2026-W32. Eine 53. Woche ist nur in ISO-Jahren gueltig, die tatsaechlich 53 Wochen haben." + }, + "worksiteId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "localDate": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Lokaler Kalendertag im Format 2026-08-03 — ohne Uhrzeit, ohne Zone" + }, + "team": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "employeeId", + "interval" + ], + "additionalProperties": false + } + } + }, + "required": [ + "weekKey", + "worksiteId", + "localDate", + "team" + ], + "additionalProperties": false + }, + "UpdateWorksiteDayTeamCommand": { + "type": "object", + "properties": { + "weekKey": { + "type": "string", + "pattern": "^\\d{4}-W(0[1-9]|[1-4]\\d|5[0-3])$", + "description": "ISO-Woche im Format 2026-W32. Eine 53. Woche ist nur in ISO-Jahren gueltig, die tatsaechlich 53 Wochen haben." + }, + "worksiteDayId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "expectedLockVersion": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "team": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "UUID" + }, + "interval": { + "type": "object", + "properties": { + "startUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + }, + "endUtc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "UTC-Zeitpunkt nach ISO 8601, drei Nachkommastellen, Suffix Z" + } + }, + "required": [ + "startUtc", + "endUtc" + ], + "additionalProperties": false + } + }, + "required": [ + "employeeId", + "interval" + ], + "additionalProperties": false + } + } + }, + "required": [ + "weekKey", + "worksiteDayId", + "expectedLockVersion", + "team" + ], + "additionalProperties": false + }, "EmployeeSchedule": { "type": "object", "properties": { diff --git a/packages/contracts/src/http/planning-gateway.ts b/packages/contracts/src/http/planning-gateway.ts index 2341d19f..35c2972a 100644 --- a/packages/contracts/src/http/planning-gateway.ts +++ b/packages/contracts/src/http/planning-gateway.ts @@ -48,9 +48,12 @@ import { PlanValidationResultSchema, PlanningWindowSchema, PublishedPlanVersionSchema, + WorksiteDayDtoSchema, type CreateAssignmentCommand, + type PlanWorksiteDayCommand, type PlanningWindowQuery, type PublishPlanCommand, + type UpdateWorksiteDayTeamCommand, type ValidatePlanCommand, } from "../planning/schemas.js"; @@ -161,6 +164,42 @@ export class HttpPlanningGateway implements PlanningGateway { }); } + planWorksiteDay( + input: PlanWorksiteDayCommand, + options: WriteOptions, + ): Promise>> { + return this.send({ + path: "/planung/baustellentage", + method: "POST", + body: input, + idempotencyKey: options.idempotencyKey, + schema: WorksiteDayDtoSchema, + // Ein 409 ist hier eine fachliche Ablehnung — der Tag existiert schon, + // oder ein Intervall passt nicht. Kein veralteter Stand: beim Anlegen + // gibt es noch keinen, auf dem der Client haette arbeiten koennen. + conflictAs: "REJECTED", + }); + } + + updateWorksiteDayTeam( + input: UpdateWorksiteDayTeamCommand, + options: WriteOptions, + ): Promise>> { + return this.send({ + path: "/planung/baustellentage/team", + method: "POST", + body: input, + idempotencyKey: options.idempotencyKey, + schema: WorksiteDayDtoSchema, + // Dieselbe Bedeutung wie beim Veroeffentlichen: `expectedLockVersion` + // passte nicht, jemand anders hat den Tag zwischenzeitlich umgeplant. Die + // Oberflaeche muss darauf neu laden — nicht die Eingabe der Planerin + // bemaengeln, an der nichts falsch war. Welcher der 409-Typen es genau + // war, steht in `problem.type` (WORKSITE_DAY_PROBLEM_TYPE). + conflictAs: "STALE_VERSION", + }); + } + private async send(call: Call): Promise> { const headers: Record = { accept: "application/json" }; if (call.body !== undefined) headers["content-type"] = "application/json"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4bd1c342..1c7d764e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,11 +14,12 @@ export { IdempotencyKeySchema, INSTANT_PATTERN, InstantSchema, + LocalDateSchema, ProblemDocumentSchema, cursorPage, newIdempotencyKey, } from "./primitives.js"; -export type { IdempotencyKey, Instant, ProblemDocument } from "./primitives.js"; +export type { IdempotencyKey, Instant, LocalDate, ProblemDocument } from "./primitives.js"; export { GATEWAY_FAILURES, gatewayFailed, gatewayOk } from "./gateway.js"; export type { GatewayFailure, GatewayResult } from "./gateway.js"; @@ -29,6 +30,7 @@ export { ConflictCodeSchema, CreateAssignmentCommandSchema, PlanValidationResultSchema, + PlanWorksiteDayCommandSchema, PlanningConflictDtoSchema, IsoWeekKeySchema, PlanningResourceSchema, @@ -39,12 +41,19 @@ export { PublishPlanCommandSchema, PublishedPlanVersionSchema, TimeIntervalDtoSchema, + UpdateWorksiteDayTeamCommandSchema, ValidatePlanCommandSchema, + WORKSITE_DAY_PROBLEM_TYPE, + WorksiteDayDtoSchema, + WorksiteDayTeamCommandSchema, + WorksiteDayTeamEntrySchema, + WorksiteDayTeamMemberSchema, } from "./planning/schemas.js"; export type { AssignmentDto, CreateAssignmentCommand, PlanValidationResult, + PlanWorksiteDayCommand, PlanningConflictDto, PlanningResource, PlanningResources, @@ -54,7 +63,12 @@ export type { PublishPlanCommand, PublishedPlanVersion, TimeIntervalDto, + UpdateWorksiteDayTeamCommand, ValidatePlanCommand, + WorksiteDayDto, + WorksiteDayProblemType, + WorksiteDayTeamEntry, + WorksiteDayTeamMember, } from "./planning/schemas.js"; export type { PlanningGateway } from "./planning/gateway.js"; diff --git a/packages/contracts/src/mock/planning.ts b/packages/contracts/src/mock/planning.ts index 00065178..4cb0b03e 100644 --- a/packages/contracts/src/mock/planning.ts +++ b/packages/contracts/src/mock/planning.ts @@ -15,15 +15,24 @@ import type { PlanningGateway } from "../planning/gateway.js"; import type { AssignmentDto, CreateAssignmentCommand, + PlanWorksiteDayCommand, PlanValidationResult, PlanningWindow, PlanningResources, PlanningWindowQuery, PublishPlanCommand, PublishedPlanVersion, + UpdateWorksiteDayTeamCommand, + WorksiteDayDto, + WorksiteDayTeamEntry, ValidatePlanCommand, } from "../planning/schemas.js"; -import { CreateAssignmentCommandSchema, ValidatePlanCommandSchema } from "../planning/schemas.js"; +import { + CreateAssignmentCommandSchema, + PlanWorksiteDayCommandSchema, + UpdateWorksiteDayTeamCommandSchema, + ValidatePlanCommandSchema, +} from "../planning/schemas.js"; import { IdempotencyKeySchema } from "../primitives.js"; /** @@ -61,6 +70,29 @@ export interface MockPlanningState { PlanValidationResult | ((input: ValidatePlanCommand) => PlanValidationResult); /** Erzeugte Id fuer `createAssignment`. Kein Zufall, damit Tests reproduzierbar bleiben. */ readonly nextAssignmentId: string; + /** + * Ids fuer den Baustellentag, den `planWorksiteDay` zurueckgibt. + * + * Ohne Vorgabewert, aus demselben Grund wie `resources`: was der Clickdummy + * zeigt, hat ein Test ausdruecklich hingeschrieben. Eine eingebaute Konstante + * liesse eine Antwort echt aussehen, ohne dass jemand sie gewaehlt hat. + * + * `assignmentIds` werden den Teameintraegen der Reihe nach zugeteilt. Reicht + * die Liste nicht, antwortet der Mock mit einer Ablehnung, die genau das sagt + * — er erfindet keine Id und schneidet das Team nicht still ab. + */ + readonly nextWorksiteDay: { + readonly worksiteDayId: string; + /** + * Die Baustelle des Tages. Sie MUSS im Fixture stehen: das Aenderungskommando + * nennt sie nicht, und ohne sie gaebe es hier nichts Richtiges zu uebergeben. + */ + readonly worksiteId: string; + /** Der lokale Tag der Identitaet. Das Aenderungskommando nennt ihn nicht — der Mock muss ihn also mitbekommen haben. */ + readonly localDate: string; + readonly configurationId: string; + readonly assignmentIds: readonly string[]; + }; } export class MockPlanningGateway implements PlanningGateway { @@ -210,6 +242,133 @@ export class MockPlanningGateway implements PlanningGateway { return Promise.resolve(gatewayOk(assignment)); } + /** + * Legt einen Baustellentag an — als ANTWORTFORM, nicht als Fachlogik. + * + * Der Mock fuehrt keinen Tagesbestand: er prueft das Kommando gegen das + * Schema und setzt aus den Fixture-Ids eine vertragskonforme Antwort + * zusammen. Er erkennt deshalb weder ein Duplikat noch eine Kollision mit + * bestehenden Zuweisungen. Wer das pruefen will, braucht den echten Server — + * dieselbe Grenze, die schon fuer `validateDraft` gilt. + */ + planWorksiteDay( + input: PlanWorksiteDayCommand, + options: WriteOptions, + ): Promise> { + const failed = this.#maybeFail(); + if (failed !== null) return Promise.resolve(failed); + + const parsed = PlanWorksiteDayCommandSchema.safeParse(input); + const key = IdempotencyKeySchema.safeParse(options.idempotencyKey); + if (!parsed.success || !key.success) { + // Der Mock sagt NICHT, welche Regel gebrochen wurde. Eine geratene + // Zuordnung waere schlimmer als keine: sie schickte den Leser auf die + // falsche Faehrte, und der echte Server vergibt seine eigene. + return Promise.resolve( + planningRejected( + "urn:easytree:planning:invalid-interval", + "Baustellentag unvollstaendig, Team leer oder Intervall ungueltig", + 400, + ), + ); + } + + return Promise.resolve( + this.#baustellentag({ + worksiteId: parsed.data.worksiteId, + localDate: parsed.data.localDate, + lockVersion: 0, + team: parsed.data.team, + }), + ); + } + + /** + * Ersetzt die Tagesbesetzung — mit einer FORTGESCHRIEBENEN `lockVersion`. + * + * Ehrlichkeitshinweis: der Mock kennt den echten Stand des Tages nicht und + * kann deshalb keinen veralteten erkennen. Er schreibt schlicht die vom + * Aufrufer genannte Erwartung um eins fort. Der `STALE_WORKSITE_DAY`-Fall ist + * hier NICHT ueberbar — er gehoert dem echten Server. + */ + updateWorksiteDayTeam( + input: UpdateWorksiteDayTeamCommand, + options: WriteOptions, + ): Promise> { + const failed = this.#maybeFail(); + if (failed !== null) return Promise.resolve(failed); + + const parsed = UpdateWorksiteDayTeamCommandSchema.safeParse(input); + const key = IdempotencyKeySchema.safeParse(options.idempotencyKey); + if (!parsed.success || !key.success) { + return Promise.resolve( + planningRejected( + "urn:easytree:planning:invalid-interval", + "Teamaenderung unvollstaendig, Team leer oder Intervall ungueltig", + 400, + ), + ); + } + + return Promise.resolve( + this.#baustellentag({ + // Baustelle UND Tag haengen an der IDENTITAET und stehen bewusst nicht im + // Kommando — der Mock nimmt beide aus seinem Fixture. + worksiteId: this.state.nextWorksiteDay.worksiteId, + localDate: this.state.nextWorksiteDay.localDate, + lockVersion: parsed.data.expectedLockVersion + 1, + team: parsed.data.team, + worksiteDayId: parsed.data.worksiteDayId, + }), + ); + } + + /** + * Setzt die Antwortform zusammen — ohne Fachlogik, nur aus Fixture-Ids. + * + * BENANNTE Felder statt Stellungsparameter, und das ist kein Stil: drei + * aufeinanderfolgende `string`-Parameter fuer `worksiteId`, `localDate` und + * `worksiteDayId` liessen sich vertauschen, ohne dass Typsystem, Schema oder + * ein Rundlauf durch `parse` es meldeten. Mit Feldnamen muss ein Tausch + * ausgeschrieben werden und faellt beim Lesen auf. + */ + #baustellentag(eingabe: { + readonly worksiteId: string; + readonly localDate: string; + readonly lockVersion: number; + readonly team: readonly WorksiteDayTeamEntry[]; + readonly worksiteDayId?: string; + }): GatewayResult { + const { worksiteId, localDate, lockVersion, team } = eingabe; + const worksiteDayId = eingabe.worksiteDayId ?? this.state.nextWorksiteDay.worksiteDayId; + const ids = this.state.nextWorksiteDay.assignmentIds; + if (team.length > ids.length) { + return planningRejected( + "urn:easytree:planning:invalid-interval", + `Der Clickdummy haelt ${ids.length} Assignment-Ids bereit, das Team hat ${team.length}. ` + + "Fixture erweitern statt Ids zu erfinden.", + 400, + ); + } + return gatewayOk({ + worksiteDayId, + configurationId: this.state.nextWorksiteDay.configurationId, + worksiteId, + localDate, + lockVersion, + team: team.map((eintrag, index) => { + const assignmentId = ids[index]; + // Der Laengenriegel oben schliesst das aus. Ein `?? ""` stuende hier + // als stille Ersatz-Id und ergaebe eine Antwort, die kein Schema mehr + // annimmt — der Fehler waere dann beim Aufrufer statt hier. + if (assignmentId === undefined) { + throw new Error("Clickdummy: Assignment-Id fehlt trotz Laengenpruefung"); + } + return { assignmentId, employeeId: eintrag.employeeId, interval: eintrag.interval }; + }), + }); + } + publishPlan(input: PublishPlanCommand): Promise> { const failed = this.#maybeFail(); if (failed !== null) return Promise.resolve(failed); diff --git a/packages/contracts/src/openapi/document.ts b/packages/contracts/src/openapi/document.ts index 6fb9a065..a16ddc35 100644 --- a/packages/contracts/src/openapi/document.ts +++ b/packages/contracts/src/openapi/document.ts @@ -36,13 +36,19 @@ import { AssignmentDtoSchema, CreateAssignmentCommandSchema, PlanValidationResultSchema, + PlanWorksiteDayCommandSchema, PlanningConflictDtoSchema, PlanningResourceSchema, PlanningWindowSchema, PublishPlanCommandSchema, PublishedPlanVersionSchema, TimeIntervalDtoSchema, + UpdateWorksiteDayTeamCommandSchema, ValidatePlanCommandSchema, + WORKSITE_DAY_PROBLEM_TYPE, + WorksiteDayDtoSchema, + WorksiteDayTeamEntrySchema, + WorksiteDayTeamMemberSchema, } from "../planning/schemas.js"; import { LoginCommandSchema, SessionDtoSchema } from "../auth/schemas.js"; import { @@ -86,6 +92,38 @@ const NAMED_SCHEMAS = { PlanValidationResult: PlanValidationResultSchema, PublishPlanCommand: PublishPlanCommandSchema, PublishedPlanVersion: PublishedPlanVersionSchema, + // EYT-147 M2: die baustellenzentrierte Planung. Zwei Ids mit verschiedener + // Lebensdauer — `worksiteDayId` ist versionsuebergreifend stabil, + // `configurationId` gilt in genau einer Planversion. Ein Generator soll + // beide als getrennte Felder sehen und nicht als denselben Begriff. + // + // ## Ehrlichkeitshinweis: VIER Regeln fallen bei der Erzeugung weg + // + // 1. `WorksiteDayTeamCommandSchema.superRefine` verbietet doppelte + // `(employeeId, interval)`-Paare und ueberlappende Intervalle derselben + // Person. Beides sind Beziehungen zwischen ARRAYELEMENTEN; JSON Schema + // kann sie nicht ausdruecken. + // 2. `LocalDateSchema` verlangt zusaetzlich zum Muster einen realen + // Kalendertag. Im Dokument steht nur `pattern` — `2026-02-30` geht dort + // durch. + // 3. Die Querbezuege von `PlanningWindow.worksiteDays` auf `assignments` + // und `resources.worksites` sind Feldbeziehungen und erscheinen ebenfalls + // nicht. + // 4. Ebenso wenig die Regel "worksiteDays nicht leer => sourceVersion nicht + // null". + // + // Was dagegen ANKOMMT und in `openapi-drift.test.ts` gemessen wird: + // `minItems: 1` fuer `team`, beide Ids unter `required`, `worksiteDays` NICHT + // unter `required` (additiv), `additionalProperties: false`. + // + // Ein generierter Client kann die vier Regeln oben also verletzen. Abgelehnt + // wird es trotzdem — zur Laufzeit, von diesen Schemata. Dieselbe + // Grenzziehung wie bei `weekKeyParam` und `TimeIntervalDtoSchema`. + WorksiteDayTeamEntry: WorksiteDayTeamEntrySchema, + WorksiteDayTeamMember: WorksiteDayTeamMemberSchema, + WorksiteDayDto: WorksiteDayDtoSchema, + PlanWorksiteDayCommand: PlanWorksiteDayCommandSchema, + UpdateWorksiteDayTeamCommand: UpdateWorksiteDayTeamCommandSchema, EmployeeSchedule: EmployeeScheduleSchema, ConfirmPlanCommand: ConfirmPlanCommandSchema, Confirmation: ConfirmationSchema, @@ -276,6 +314,34 @@ function jsonOk(name: NamedSchema, description: string): unknown { return { description, content: { "application/json": { schema: ref(name) } } }; } +/** + * Die 409-Antwort EINER Route, samt der `problem.type`-Werte, die dort vorkommen. + * + * `problemResponses["409"]` sagt nur "Konflikt oder veralteter Planstand" und + * laesst offen, WELCHER. Fuer die Tagescommands ist das zu wenig: der Client + * muss `STALE_WORKSITE_DAY` (neu laden) von `DUPLICATE_WORKSITE_DAY` (Eingabe + * korrigieren) unterscheiden koennen, und ohne die Liste im Vertrag bliebe ihm + * nur, die Zeichenketten aus dem Servercode abzuschreiben. + * + * Die Werte kommen aus {@link WORKSITE_DAY_PROBLEM_TYPE} und nicht als Literale + * hierher: sonst stuenden dieselben URNs an zwei Stellen, und die Kopie im + * veroeffentlichten Dokument koennte der Konstante widersprechen. + * + * Der Rest des Fehlerobjekts bleibt unangetastet — es ist dasselbe + * `ProblemDocument` wie ueberall, nur mit einer genaueren Beschreibung. + */ +function konfliktMit(...typen: readonly string[]): unknown { + return { + ...problemResponses["409"], + description: + `Konflikt oder veralteter Planstand. Moegliche problem.type-Werte dieser Route: ` + + `${typen.join(", ")}. Zusaetzlich gelten die allgemeinen Planungstypen der uebrigen ` + + `Schreibrouten unveraendert (Idempotenzschluessel wiederverwendet, ueberlappende ` + + `Zuweisung, ausserhalb der Woche, nicht auswaehlbare Ressource) - sie werden hier ` + + `wiederverwendet und nicht neu erfunden.`, + }; +} + /** Erzeugt das vollstaendige Dokument. Rein, synchron, ohne Dateizugriff. */ export function buildOpenApiDocument(): Record { const schemas: Record = {}; @@ -337,6 +403,59 @@ export function buildOpenApiDocument(): Record { }, }, }, + "/planung/baustellentage": { + post: { + operationId: "planWorksiteDay", + summary: "Baustellentag anlegen", + description: + "Legt den Baustellentag fuer eine Baustelle an einem expliziten lokalen Kalendertag an " + + "und besetzt ihn. Adressiert wird Baustelle plus Tag, nicht eine Id: die stabile " + + "worksiteDayId entsteht erst hier. Der Server loest sie auf, haengt sie an die " + + "Konfiguration des Wochenentwurfs und antwortet mit dem angelegten Tag einschliesslich " + + "lockVersion und der vergebenen assignmentIds. Neu gegenueber den uebrigen " + + "Planungsrouten sind die problem.type-Werte " + + `${WORKSITE_DAY_PROBLEM_TYPE.DUPLICATE_WORKSITE_DAY} und ` + + `${WORKSITE_DAY_PROBLEM_TYPE.INTERVAL_OUTSIDE_DAY}.`, + parameters: [idempotencyHeader], + requestBody: jsonBody("PlanWorksiteDayCommand"), + responses: { + "201": jsonOk("WorksiteDayDto", "Angelegter Baustellentag"), + ...problemResponses, + "409": konfliktMit( + WORKSITE_DAY_PROBLEM_TYPE.DUPLICATE_WORKSITE_DAY, + WORKSITE_DAY_PROBLEM_TYPE.INTERVAL_OUTSIDE_DAY, + ), + }, + }, + }, + "/planung/baustellentage/team": { + post: { + operationId: "updateWorksiteDayTeam", + summary: "Tagesbesetzung eines Baustellentags ersetzen", + description: + "Ersetzt die Besetzung eines BESTEHENDEN Baustellentags. Adressiert wird die stabile " + + "worksiteDayId - nicht die configurationId, denn die Revision ist durch weekKey " + + "eindeutig (hoechstens ein Entwurf je Woche), und nicht der localDate, denn der " + + "haengt unveraenderlich an der Identitaet. expectedLockVersion ist die " + + "Stale-Erkennung gegen die Tagesidentitaet (lock_version wird beim Kopieren in den " + + "Folgedraft uebernommen); weicht sie vom Server ab, " + + `antwortet er mit ${WORKSITE_DAY_PROBLEM_TYPE.STALE_WORKSITE_DAY}, statt fremde ` + + "Aenderungen zu ueberschreiben. Kennt er die Id nicht, mit " + + `${WORKSITE_DAY_PROBLEM_TYPE.WORKSITE_DAY_NOT_FOUND}. Die Antwort traegt die ` + + "fortgeschriebene lockVersion, damit die naechste Aenderung ohne Zwischenladen moeglich ist.", + parameters: [idempotencyHeader], + requestBody: jsonBody("UpdateWorksiteDayTeamCommand"), + responses: { + "200": jsonOk("WorksiteDayDto", "Baustellentag mit ersetzter Besetzung"), + ...problemResponses, + "409": konfliktMit( + WORKSITE_DAY_PROBLEM_TYPE.STALE_WORKSITE_DAY, + WORKSITE_DAY_PROBLEM_TYPE.WORKSITE_DAY_NOT_FOUND, + WORKSITE_DAY_PROBLEM_TYPE.INTERVAL_OUTSIDE_DAY, + ), + }, + }, + }, "/einsatz/plan": { get: { operationId: "getMySchedule", diff --git a/packages/contracts/src/planning/gateway.ts b/packages/contracts/src/planning/gateway.ts index d05f1e37..e7f751cf 100644 --- a/packages/contracts/src/planning/gateway.ts +++ b/packages/contracts/src/planning/gateway.ts @@ -9,11 +9,14 @@ import type { AssignmentDto, CreateAssignmentCommand, PlanValidationResult, + PlanWorksiteDayCommand, PlanningWindow, PlanningWindowQuery, PublishPlanCommand, PublishedPlanVersion, + UpdateWorksiteDayTeamCommand, ValidatePlanCommand, + WorksiteDayDto, } from "./schemas.js"; /** @@ -46,4 +49,28 @@ export interface PlanningGateway { input: PublishPlanCommand, options: WriteOptions, ): Promise>; + /** + * Einen Baustellentag anlegen (EYT-147 M2). + * + * Antwortet mit dem angelegten Tag, nicht mit einer nackten Id: der Aufrufer + * braucht `lockVersion` fuer die naechste Aenderung und `team` mit den + * vergebenen Assignment-Ids, um die Karte zu zeichnen. Ein zweiter Leseaufruf + * dafuer koennte bereits einen anderen Stand sehen. + */ + planWorksiteDay( + input: PlanWorksiteDayCommand, + options: WriteOptions, + ): Promise>; + /** + * Die Tagesbesetzung eines bestehenden Baustellentags ersetzen (EYT-147 M2). + * + * Gibt denselben Typ zurueck wie das Anlegen — mit der FORTGESCHRIEBENEN + * `lockVersion`. Ohne sie muesste der Client fuer jede zweite Aenderung neu + * laden, und genau in dieser Luecke entstuende der veraltete Stand, den + * `expectedLockVersion` verhindern soll. + */ + updateWorksiteDayTeam( + input: UpdateWorksiteDayTeamCommand, + options: WriteOptions, + ): Promise>; } diff --git a/packages/contracts/src/planning/schemas.ts b/packages/contracts/src/planning/schemas.ts index bab1d03a..03ae0b80 100644 --- a/packages/contracts/src/planning/schemas.ts +++ b/packages/contracts/src/planning/schemas.ts @@ -22,7 +22,7 @@ import { z } from "zod"; import { isValidIsoWeekKey } from "./iso-week.js"; -import { IdSchema, InstantSchema } from "../primitives.js"; +import { IdSchema, InstantSchema, LocalDateSchema } from "../primitives.js"; /** * Halb-offenes Intervall als Transportform. Entspricht `TimeInterval` der Domain. @@ -192,6 +192,149 @@ export const PlanningResourcesSchema = z.strictObject({ export type PlanningResources = z.infer; +/** + * Ein Teameintrag, wie ihn ein KOMMANDO traegt — Person und Intervall. + * + * Ohne Assignment-Id, und das ist kein Versehen: beim Anlegen existiert sie noch + * nicht, der Server vergibt sie. Die Antwort fuehrt sie dann mit (siehe + * {@link WorksiteDayTeamMemberSchema}). Ein gemeinsames Schema fuer beide + * Richtungen brauchte ein optionales Feld — und ein optionales Feld heisst, + * dass niemand mehr sagen kann, ob eine fehlende Id ein neuer Eintrag oder ein + * Datenverlust ist. + */ +export const WorksiteDayTeamEntrySchema = z.strictObject({ + employeeId: IdSchema, + interval: TimeIntervalDtoSchema, +}); + +export type WorksiteDayTeamEntry = z.infer; + +/** + * Die zwei Teamregeln — einmal geschrieben, von beiden Kommandos benutzt. + * + * 1. Dasselbe `(employeeId, interval)`-Paar darf nicht zweimal vorkommen. + * 2. Zwei Intervalle DERSELBEN Person duerfen sich nicht ueberlappen. + * + * Getrennte, nicht ueberlappende Intervalle derselben Person bleiben ausdruecklich + * erlaubt: jemand steht vormittags und nachmittags auf derselben Baustelle, und + * das ist ein normaler Tag, kein Konflikt (Confluence 41484289 §2 Nr. 4). + * + * ## Warum das Duplikat VOR der Ueberlappung geprueft wird + * + * Ein identisches Paar ueberlappt sich selbst. Ohne Vorrang traege der + * Doppeleintrag die Ueberlappungsmeldung, und die Planerin suchte nach einer + * Zeitkollision, wo sie in Wahrheit zweimal dieselbe Zeile abgeschickt hat. + * + * ## Warum Zeichenketten verglichen werden und nicht `Date` + * + * `InstantSchema` laesst genau EINE Schreibweise zu: UTC, drei Nachkommastellen, + * `Z`. Damit haben alle Werte dieselbe Laenge und dieselbe Stellenordnung, und + * die lexikografische Reihenfolge IST die zeitliche. Ein `Date.parse` waere ein + * zweiter Weg zur selben Aussage — und einer, der bei einem kaputten Wert still + * `NaN` liefert, womit jeder Vergleich `false` ergaebe und die Regel schwiege. + */ +function pruefeTeamregeln(eintraege: readonly WorksiteDayTeamEntry[], ctx: z.RefinementCtx): void { + for (let i = 0; i < eintraege.length; i += 1) { + for (let j = i + 1; j < eintraege.length; j += 1) { + const a = eintraege[i]; + const b = eintraege[j]; + if (a === undefined || b === undefined) continue; + if (a.employeeId !== b.employeeId) continue; + + if (a.interval.startUtc === b.interval.startUtc && a.interval.endUtc === b.interval.endUtc) { + ctx.addIssue({ + code: "custom", + path: [j], + message: + "Derselbe Eintrag steht zweimal im Team: gleiche Person, gleiches Intervall. " + + "Gemeint ist vermutlich ein zweites, anderes Intervall.", + }); + continue; + } + + if (a.interval.startUtc < b.interval.endUtc && b.interval.startUtc < a.interval.endUtc) { + ctx.addIssue({ + code: "custom", + path: [j], + message: + "Zwei Intervalle derselben Person ueberlappen sich. Getrennte Intervalle am " + + "selben Tag sind erlaubt, sich ueberschneidende nicht.", + }); + } + } + } +} + +/** + * Das Team eines Kommandos: mindestens EINE Person, und die Regeln oben. + * + * `min(1)` ist eine Produktentscheidung, keine Vorsicht: Confluence 41484289 + * §2 Nr. 8 verlangt, dass ein leerer Baustellentag nicht still entsteht. Die + * ANTWORT darf `[]` trotzdem tragen — siehe {@link WorksiteDayDtoSchema}. + * + * Die Regeln sitzen am Array und nicht am umgebenden Kommando. Das ist kein + * Stil: `z.toJSONSchema` gibt fuer ein Objekt mit `superRefine` weiterhin + * `additionalProperties: false` aus, aber die Regel gehoert fachlich zur Liste, + * und so tragen beide Kommandos sie ohne eine zweite Ableitung. + */ +export const WorksiteDayTeamCommandSchema = z + .array(WorksiteDayTeamEntrySchema) + .min(1, "Ein Baustellentag wird nicht ohne Team angelegt oder geleert") + .superRefine(pruefeTeamregeln); + +/** + * Ein Teammitglied, wie es die ANTWORT traegt — mit der Assignment-Id. + * + * Sie ist die Bruecke zur bestehenden Assignment-Projektion: dieselbe Zuweisung + * erscheint einmal unter `assignments` und einmal hier. Das Fenster prueft, dass + * beide Seiten dasselbe sagen (siehe {@link PlanningWindowSchema}). + */ +export const WorksiteDayTeamMemberSchema = z.strictObject({ + assignmentId: IdSchema, + employeeId: IdSchema, + interval: TimeIntervalDtoSchema, +}); + +export type WorksiteDayTeamMember = z.infer; + +/** + * Ein Baustellentag, wie ihn die Antwort zeigt. + * + * ## Zwei Ids, zwei Lebensdauern — und keine ist ein Alias der anderen + * + * `worksiteDayId` ist die IDENTITAET: eine Zeile je (Organisation, Baustelle, + * lokaler Tag), versionsuebergreifend stabil (Migration `0019`). Sie ueberlebt + * jede Veroeffentlichung. `configurationId` ist die REVISION: der Planungsstand + * genau dieses Tages in genau einer Planversion. Wird eine Version kopiert, + * entsteht eine NEUE Konfiguration auf DERSELBEN Identitaet. + * + * Wer die beiden zusammenlegt, verliert genau die Faehigkeit, um derentwillen + * es sie gibt: denselben Tag ueber Draft -> Publish -> Folgedraft + * wiederzuerkennen. `UpdateWorksiteDayTeamCommandSchema` adressiert deshalb die + * IDENTITAET und nennt die Revision gar nicht. + * + * ## Warum `team` hier leer sein darf + * + * Die Kommandos verbieten `[]`, dieser Vertrag erlaubt es — die Asymmetrie ist + * Absicht. Ein Antwortschema, das den leeren Tag ablehnt, macht aus EINER + * unvollstaendigen Zeile eine unlesbare GESAMTE Planungsantwort: der Client + * verwirft dann das ganze Fenster und die Planerin sieht nichts mehr. Die + * Vollstaendigkeit eines Tages ist eine Publish-Bedingung, keine Lesebedingung. + */ +export const WorksiteDayDtoSchema = z.strictObject({ + /** Stabile Identitaet des Baustellentags. Ueberlebt jede Planversion. */ + worksiteDayId: IdSchema, + /** Die konkrete revisionsgebundene Konfiguration in EINER Planversion. */ + configurationId: IdSchema, + worksiteId: IdSchema, + localDate: LocalDateSchema, + /** Fortschreibungszaehler der Konfiguration; `expectedLockVersion` vergleicht dagegen. */ + lockVersion: z.number().int().min(0), + team: z.array(WorksiteDayTeamMemberSchema), +}); + +export type WorksiteDayDto = z.infer; + export const PlanningWindowSchema = z .strictObject({ weekKey: IsoWeekKeySchema, @@ -215,6 +358,25 @@ export const PlanningWindowSchema = z * {@link PlanningResourcesSchema} — mitgeliefert, nicht nachgeladen. */ resources: PlanningResourcesSchema, + /** + * Die Baustellentage dieser Woche (EYT-147 M2) — ADDITIV, deshalb optional. + * + * Fehlt das Feld, ist die Antwort ein Bestandsfenster ohne Baustellentage + * und bleibt gueltig. Das ist keine Nachlaessigkeit, sondern die Bedingung + * dafuer, dass diese Erweiterung additiv IST: der heutige Server sendet das + * Feld nicht, und ein Pflichtfeld haette seine Antwort ab sofort als + * Vertragsbruch verworfen — die Planung waere leer, ohne dass sich am + * Server etwas geaendert haette. + * + * `.default([])` waere hier die falsche Abkuerzung: gemessen mit zod 4.4.3 + * fuehrt `z.toJSONSchema` ein Feld mit Vorgabewert unter `required`. Das + * veroeffentlichte Dokument behauptete dann ein Feld, das nie mitkommt. + * + * Die bestehende `assignments`-Projektion bleibt unberuehrt: Zuweisungen + * ohne Baustellentag sind weiterhin gueltig (Migration `0019` legt die + * Referenz ausdruecklich NULLABLE an, ohne Backfill). + */ + worksiteDays: z.array(WorksiteDayDtoSchema).optional(), }) .superRefine((fenster, ctx) => { // Feldweise Gueltigkeit genuegt hier nicht: die AUSSAGE steckt in der @@ -248,6 +410,147 @@ export const PlanningWindowSchema = z } }); + // Baustellentage (EYT-147 M2). Bewusst HIER und nicht am Ende dieser + // Funktion: die Zweige unten enden jeweils mit `return`, und angehaengte + // Regeln liefen bei `sourceVersion === null` oder `"published"` nie — + // ausgerechnet der Fall, den Regel (6) pruefen soll. + const tage = fenster.worksiteDays; + if (tage !== undefined) { + // Jede Zuweisung EINMAL nachschlagbar machen. Ein lineares Suchen je + // Teammitglied waere nicht nur quadratisch, es verleitete auch dazu, den + // Treffer als Beweis zu nehmen — geprueft werden muss er trotzdem. + const einsaetze = new Map(fenster.assignments.map((einsatz) => [einsatz.id, einsatz])); + /** Assignment-Id -> Index des Tages, dessen Team sie zuerst nannte. */ + const belegtVon = new Map(); + /** worksiteDayId -> Index der Zeile, die diese Identitaet zuerst fuehrte. */ + const identitaeten = new Map(); + /** configurationId -> Index der Zeile, die diese Revision zuerst fuehrte. */ + const revisionen = new Map(); + + tage.forEach((tag, tagIndex) => { + if (!bekannteBaustellen.has(tag.worksiteId)) { + ctx.addIssue({ + code: "custom", + path: ["worksiteDays", tagIndex, "worksiteId"], + message: + "Baustellentag verweist auf eine Baustelle, die nicht in resources.worksites steht — nicht aufloesbar.", + }); + } + + // Eine Tagesidentitaet und eine Konfigurationsrevision erscheinen im + // Fenster je HOECHSTENS EINMAL als primaere Zeile. Beide Achsen einzeln, + // nicht als Paar: `(worksiteDayId, configurationId)` waere schon dann + // eindeutig, wenn derselbe Tag zweimal mit verschiedenen Revisionen + // stuende — und genau das ist die zweite Stale-Wahrheit je Tag, die + // Migration 0019 R-20 ausschliesst. Umgekehrt gilt `unique + // (plan_version_id, worksite_day_id)`: eine Revision gehoert zu genau + // einer Identitaet. + const identitaetZuerst = identitaeten.get(tag.worksiteDayId); + if (identitaetZuerst === undefined) { + identitaeten.set(tag.worksiteDayId, tagIndex); + } else { + ctx.addIssue({ + code: "custom", + path: ["worksiteDays", tagIndex, "worksiteDayId"], + message: + `Dieselbe Tagesidentitaet steht bereits in worksiteDays[${identitaetZuerst}]. ` + + "Ein Baustellentag erscheint je Fenster einmal — sonst traegt er zwei lockVersion-Staende.", + }); + } + + const revisionZuerst = revisionen.get(tag.configurationId); + if (revisionZuerst === undefined) { + revisionen.set(tag.configurationId, tagIndex); + } else { + ctx.addIssue({ + code: "custom", + path: ["worksiteDays", tagIndex, "configurationId"], + message: + `Dieselbe Konfigurationsrevision steht bereits in worksiteDays[${revisionZuerst}]. ` + + "Eine Tageskonfiguration gehoert zu genau einer Tagesidentitaet.", + }); + } + + tag.team.forEach((mitglied, mitgliedIndex) => { + const pfad: (string | number)[] = ["worksiteDays", tagIndex, "team", mitgliedIndex]; + const einsatz = einsaetze.get(mitglied.assignmentId); + + if (einsatz === undefined) { + ctx.addIssue({ + code: "custom", + path: [...pfad, "assignmentId"], + message: + "Teammitglied verweist auf eine Zuweisung, die nicht unter assignments steht. " + + "Die Tageskarte zeigte damit eine Besetzung, die das Fenster selbst nicht kennt.", + }); + return; + } + + // Die Tageskarte und die Zuweisungsliste sind ZWEI Sichten auf + // dieselbe Tatsache. Weichen sie ab, zeigt die Oberflaeche je nach + // Ansicht etwas anderes an — und beide Male sieht es richtig aus. + if (einsatz.employeeId !== mitglied.employeeId) { + ctx.addIssue({ + code: "custom", + path: [...pfad, "employeeId"], + message: "Teammitglied und referenzierte Zuweisung nennen verschiedene Personen.", + }); + } + if ( + einsatz.interval.startUtc !== mitglied.interval.startUtc || + einsatz.interval.endUtc !== mitglied.interval.endUtc + ) { + ctx.addIssue({ + code: "custom", + path: [...pfad, "interval"], + message: "Teammitglied und referenzierte Zuweisung nennen verschiedene Intervalle.", + }); + } + // Dritte Achse derselben Aussage — und die einzige, die nicht aus dem + // Teameintrag selbst kommt: die Baustelle steht am TAG, nicht am + // Mitglied. Person und Intervall koennen vollstaendig uebereinstimmen, + // waehrend die Zuweisung auf einer anderen Baustelle gefuehrt wird; + // beide Ansichten saehen dann fuer sich richtig aus. Die + // Aufloesbarkeitspruefung oben faengt das nicht: sie fragt nur, ob die + // Baustelle BEKANNT ist, nicht ob es DIESELBE ist. + if (einsatz.worksiteId !== tag.worksiteId) { + ctx.addIssue({ + code: "custom", + path: [...pfad, "assignmentId"], + message: + "Teammitglied verweist auf eine Zuweisung einer ANDEREN Baustelle als der des Baustellentags.", + }); + } + + const zuerst = belegtVon.get(mitglied.assignmentId); + if (zuerst === undefined) { + belegtVon.set(mitglied.assignmentId, tagIndex); + } else { + // Eine Zuweisung gehoert zu genau EINER Tageskonfiguration + // (Migration `0019`: `assignments.worksite_day_configuration_id` ist + // eine einzelne Spalte). Zweimal genannt hiesse, dieselbe Person + // waere zur selben Zeit auf zwei Karten verplant. + ctx.addIssue({ + code: "custom", + path: [...pfad, "assignmentId"], + message: + `Dieselbe Zuweisung steht bereits im Team von worksiteDays[${zuerst}]. ` + + "Eine Zuweisung gehoert zu genau einem Baustellentag.", + }); + } + }); + }); + + if (tage.length > 0 && fenster.sourceVersion === null) { + ctx.addIssue({ + code: "custom", + path: ["worksiteDays"], + message: + "Ohne sourceVersion kann es keine Baustellentage geben — sie haetten keine Herkunft.", + }); + } + } + if (fenster.sourceVersion === null) { if (fenster.assignments.length > 0) { ctx.addIssue({ @@ -368,3 +671,120 @@ export const PublishedPlanVersionSchema = z.strictObject({ }); export type PublishedPlanVersion = z.infer; + +/** + * Einen Baustellentag anlegen (EYT-147 M2). + * + * Adressiert wird die Baustelle und der lokale Tag — nicht eine Id, denn die + * gibt es noch nicht. Der Server loest daraus die Identitaet auf (anlegen oder + * finden) und haengt sie an die Konfiguration des Wochenentwurfs. Welche + * Planversion das ist, entscheidet ausschliesslich er ueber + * `app.lock_week_draft(weekKey)` (Confluence 41484289 §5); ein Kommando, das + * die Revision mitbraechte, koennte ihr widersprechen. + * + * `weekKey` ist dieselbe Redundanz wie bei {@link CreateAssignmentCommandSchema}: + * die Woche, die die Planerin GEOEFFNET hat. Der Server rechnet sie aus + * `localDate` in der Zone der Organisation selbst aus und vergleicht. Ohne das + * Feld landete ein Tag am Wochenrand still in einer Woche, die niemand ansieht. + */ +export const PlanWorksiteDayCommandSchema = z.strictObject({ + weekKey: IsoWeekKeySchema, + worksiteId: IdSchema, + localDate: LocalDateSchema, + team: WorksiteDayTeamCommandSchema, +}); + +export type PlanWorksiteDayCommand = z.infer; + +/** + * Die Tagesbesetzung eines bestehenden Baustellentags ersetzen (EYT-147 M2). + * + * ## Was hier bewusst FEHLT + * + * Kein `configurationId` und kein `localDate`. Beide waeren stille + * Zweitwahrheiten: die Revision ist durch `weekKey` eindeutig — hoechstens ein + * unveroeffentlichter Entwurf je Woche (Confluence 41484289 §4) —, und der Tag + * haengt unveraenderlich an der Identitaet. Ein Kommando, das sie mitschickte, + * koennte ihnen widersprechen — und dann muesste jemand entscheiden, welche + * Angabe gewinnt. Diese Entscheidung gibt es hier nicht, weil die Frage nicht + * gestellt werden kann. + * + * Adressiert wird deshalb die STABILE `worksiteDayId`. Sie ueberlebt + * Draft -> Publish -> Folgedraft; eine Konfigurations-Id waere nach der + * naechsten Veroeffentlichung ins Leere gelaufen, obwohl sich am Tag nichts + * geaendert hat. + * + * `expectedLockVersion` ist die Stale-Erkennung, dieselbe Bauart wie + * `expectedVersionId` beim Veroeffentlichen: der Client sagt, auf welchem Stand + * er arbeitet. `lock_version` ist das Nebenlaeufigkeits-Token der + * TAGESIDENTITAET, wird beim Kopieren in den Folgedraft uebernommen und nur + * durch eine reale Tagesmutation erhoeht (Migration 0019, R-20: "genau EINE + * Stale-Wahrheit je Tag"). Weicht der Server ab, hat jemand anders den Tag + * zwischenzeitlich umgeplant, und das Kommando wird abgelehnt, statt fremde + * Arbeit zu ueberschreiben. + */ +export const UpdateWorksiteDayTeamCommandSchema = z.strictObject({ + weekKey: IsoWeekKeySchema, + worksiteDayId: IdSchema, + expectedLockVersion: z.number().int().min(0), + team: WorksiteDayTeamCommandSchema, +}); + +export type UpdateWorksiteDayTeamCommand = z.infer; + +/** + * Die Problem-Types der Tagescommands — das VOKABULAR, nicht die Implementierung. + * + * Sie stehen hier, weil sie Teil des Transportvertrags sind: ein Client + * unterscheidet daran, ob er neu laden (`STALE_WORKSITE_DAY`), den Tag suchen + * (`WORKSITE_DAY_NOT_FOUND`) oder die Eingabe korrigieren muss. Die + * Zuordnung Domainfehler -> URN gehoert dagegen an die HTTP-Naht in `apps/api` + * (`PLANNING_ERROR_TYPE` in `planning-problem.filter.ts`) und entsteht mit der + * Businesslogik (M3/M4) — die dort diese Konstante IMPORTIERT, damit die URNs + * nicht an zwei Stellen stehen. + * + * Damit diese Liste kein blosser Kommentar bleibt, werden die ERREICHBAREN + * 409-Werte in die 409-Beschreibung der beiden Routen eingesetzt + * (`openapi/document.ts`) und sind dadurch Teil des erzeugten `v1.json` — der + * Drift-Test schuetzt sie byteweise mit. + * + * ## `WORKSITE_DAY_TEAM_REQUIRED` ist KEIN 409 dieser Routen + * + * Die Konstante bleibt, der veroeffentlichte 409-Zweig nicht (PO-Review 15164). + * `WorksiteDayTeamCommandSchema.min(1)` lehnt ein leeres Team schon an der + * Request-Grenze ab, und der Planungscontroller beantwortet einen + * fehlgeschlagenen `safeParse` ausnahmslos mit **400** (`BadRequestException`, + * `planning.controller.ts`) — nach erfolgreicher Schemapruefung kann der Fall + * nicht mehr entstehen. Ein veroeffentlichter 409 dafuer waere ein Versprechen + * an Clients, das der Server nie einloest. + * + * Der Wert selbst bleibt benannt, weil M3/M4 ihn an der 400-Grenze als + * `problem.type` fuehren sollen: „Team fehlt" ist fachlich etwas anderes als + * `INVALID_INTERVAL`, und ein Client soll beides unterscheiden koennen, ohne + * den deutschen Meldungstext zu lesen. + * + * NICHT enthalten: `WORKSITE_DAY_INCOMPLETE`. Das ist ein + * VEROEFFENTLICHUNGS-Problem und gehoert weder hierher noch in + * {@link CONFLICT_CODE_VALUES} — ein unvollstaendiger Tag ist beim Lesen und + * Bearbeiten voellig in Ordnung und wird erst beim Publish zum Hindernis (M5). + */ +export const WORKSITE_DAY_PROBLEM_TYPE = { + /** Die genannte `worksiteDayId` gibt es in dieser Organisation nicht. */ + WORKSITE_DAY_NOT_FOUND: "urn:easytree:planning:worksite-day-not-found", + /** `expectedLockVersion` passt nicht: der Tag wurde zwischenzeitlich umgeplant. */ + STALE_WORKSITE_DAY: "urn:easytree:planning:stale-worksite-day", + /** Fuer diese Baustelle und diesen Tag existiert bereits ein Baustellentag. */ + DUPLICATE_WORKSITE_DAY: "urn:easytree:planning:duplicate-worksite-day", + /** Ein Intervall liegt ausserhalb des lokalen Tages, den es besetzen soll. */ + INTERVAL_OUTSIDE_DAY: "urn:easytree:planning:interval-outside-day", + /** + * Das Kommando kam ohne Team an; ein leerer Tag entsteht nicht still. + * + * Request-Grenze, also **400** — nicht Teil der 409-Zweige der beiden Routen + * (siehe Kopfkommentar). `team.min(1)` faengt den Fall vorher ab. + */ + WORKSITE_DAY_TEAM_REQUIRED: "urn:easytree:planning:worksite-day-team-required", +} as const; + +export type WorksiteDayProblemType = + (typeof WORKSITE_DAY_PROBLEM_TYPE)[keyof typeof WORKSITE_DAY_PROBLEM_TYPE]; diff --git a/packages/contracts/src/primitives.ts b/packages/contracts/src/primitives.ts index 63d071f1..1a3e683c 100644 --- a/packages/contracts/src/primitives.ts +++ b/packages/contracts/src/primitives.ts @@ -48,6 +48,45 @@ export type Instant = z.infer; /** UUID v4 als Bezeichner. Die nominale Härtung liegt in der Domain, nicht im Transport. */ export const IdSchema = z.uuid().describe("UUID"); +/** + * Lokaler Kalendertag ohne Uhrzeit und ohne Zone — `2026-08-03`. + * + * ## Warum das Muster allein nicht genuegt + * + * `^\d{4}-\d{2}-\d{2}$` nimmt `2026-02-30` an. Diesen Tag gibt es nicht, und ein + * Baustellentag darauf koennte nie aus `worksite_days.local_date` (Typ `date`) + * stammen — die Datenbank haette die Zeile abgelehnt. Ein Vertrag, der + * nachsichtiger ist als der Speicher, verschiebt den Fehler nur nach hinten. + * + * Geprueft wird ueber den Rueckweg, dieselbe Bauart wie bei {@link InstantSchema}: + * `Date` normalisiert `2026-02-30` still zum 2. Maerz, und der Vergleich mit der + * Eingabe faellt dann auseinander (gemessen: `2026-02-30` -> `2026-03-02`, + * `2026-04-31` -> `2026-05-01`, `2025-02-29` -> `2025-03-01`; `2026-13-01` und + * `2026-00-10` ergeben gar kein Datum). Das angehaengte `T00:00:00.000Z` gehoert + * dazu: ohne Zeitanteil duerfte eine Laufzeit den Wert als LOKALE Zeit lesen, und + * eine westliche Zone verschoebe den Rueckweg um einen Tag. + * + * ## Abgrenzung zu `BusinessDateSchema` + * + * `costs/schemas.ts` fuehrt fuer denselben Begriff ein SCHWAECHERES Schema: nur + * das Muster, ohne Kalenderregel. Das ist bekannt und bleibt hier absichtlich + * unberuehrt — es zu verschaerfen aenderte die Laufzeitsemantik von + * Satzversionen und Kostenpositionen, und das ist keine additive + * Vertragserweiterung. Bis die beiden jemand mit eigenen Kostentests + * zusammenfuehrt, ist DIES die strengere Seite, und neue Felder nehmen dieses + * Schema, nicht das andere. + */ +export const LocalDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Lokales Datum im Format JJJJ-MM-TT") + .refine((wert) => { + const parsed = new Date(`${wert}T00:00:00.000Z`); + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === wert; + }, "Kein realer Kalendertag im gregorianischen Kalender") + .describe("Lokaler Kalendertag im Format 2026-08-03 — ohne Uhrzeit, ohne Zone"); + +export type LocalDate = z.infer; + /** * Fehlerantwort. * diff --git a/packages/contracts/test/http-planning-gateway.test.ts b/packages/contracts/test/http-planning-gateway.test.ts index 0f16b20a..8472b1ef 100644 --- a/packages/contracts/test/http-planning-gateway.test.ts +++ b/packages/contracts/test/http-planning-gateway.test.ts @@ -327,3 +327,117 @@ describe("HttpPlanningGateway — Aufrufform", () => { ); }); }); + +/** + * Die Baustellentag-Kommandos (EYT-147 M2). + * + * Ohne diese Faelle war an den beiden neuen Methoden NICHTS bewacht: Pfad, + * Methode, Idempotenzkopf und die 409-Deutung standen nur im Quelltext. Der + * Drift-Test prueft das erzeugte DOKUMENT, der Konformitaetstest Dokument gegen + * SERVERROUTEN — keiner von beiden sieht, wohin dieser Client tatsaechlich + * greift. Ein Tippfehler im Pfad waere durch alle Gates gelaufen und erst in + * einer Browserreise als 404 aufgefallen. + */ +const BAUSTELLENTAG = { + worksiteDayId: "44444444-4444-4444-8444-444444444444", + configurationId: "55555555-5555-4555-8555-555555555555", + worksiteId: "66666666-6666-4666-8666-666666666666", + localDate: "2026-08-03", + lockVersion: 0, + team: [], +} as const; + +const ANLEGEN = { + weekKey: "2026-W32", + worksiteId: BAUSTELLENTAG.worksiteId, + localDate: "2026-08-03", + team: [ + { + employeeId: "77777777-7777-4777-8777-777777777777", + interval: { startUtc: "2026-08-03T06:00:00.000Z", endUtc: "2026-08-03T10:00:00.000Z" }, + }, + ], +}; + +const TEAMWECHSEL = { + weekKey: "2026-W32", + worksiteDayId: BAUSTELLENTAG.worksiteDayId, + expectedLockVersion: 0, + team: ANLEGEN.team, +}; + +describe("HttpPlanningGateway — Baustellentag-Kommandos (EYT-147 M2)", () => { + it("greift beim Anlegen auf POST /planung/baustellentage", async () => { + const recorded: Recorded[] = []; + const result = await gatewayWith(stubFetch(201, BAUSTELLENTAG, recorded)).planWorksiteDay( + ANLEGEN, + { idempotencyKey: KEY_A }, + ); + expect(result.ok).toBe(true); + // Die Adresse ganz, nicht als Teilstring: `toContain` waere auch bei + // "/planung/baustellentage/team" gruen und truege damit genau die + // Verwechslung mit, gegen die dieser Fall existiert. + expect(recorded[0]?.url).toBe(`${BASE}/planung/baustellentage`); + expect(recorded[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual(ANLEGEN); + }); + + it("greift beim Teamwechsel auf POST /planung/baustellentage/team", async () => { + const recorded: Recorded[] = []; + const result = await gatewayWith(stubFetch(200, BAUSTELLENTAG, recorded)).updateWorksiteDayTeam( + TEAMWECHSEL, + { idempotencyKey: KEY_A }, + ); + expect(result.ok).toBe(true); + expect(recorded[0]?.url).toBe(`${BASE}/planung/baustellentage/team`); + expect(recorded[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual(TEAMWECHSEL); + }); + + it("sendet bei beiden Kommandos den Idempotenzschluessel", async () => { + for (const aufruf of ["planWorksiteDay", "updateWorksiteDayTeam"] as const) { + const recorded: Recorded[] = []; + const gateway = gatewayWith( + stubFetch(aufruf === "planWorksiteDay" ? 201 : 200, BAUSTELLENTAG, recorded), + ); + await (aufruf === "planWorksiteDay" + ? gateway.planWorksiteDay(ANLEGEN, { idempotencyKey: KEY_A }) + : gateway.updateWorksiteDayTeam(TEAMWECHSEL, { idempotencyKey: KEY_A })); + const headers = recorded[0]?.init?.headers as Record | undefined; + expect(headers?.[IDEMPOTENCY_HEADER], `${aufruf} ohne Schluessel`).toBe(KEY_A); + } + }); + + it("deutet 409 je Kommando verschieden: Teamwechsel ist STALE_VERSION", async () => { + // Der Unterschied ist der ganze Punkt. Beim Anlegen gibt es keinen Stand, + // auf dem der Client haette arbeiten koennen — ein 409 ist dort eine + // fachliche Ablehnung. Beim Teamwechsel passte `expectedLockVersion` nicht, + // und die Oberflaeche muss neu laden statt die Eingabe zu bemaengeln. + const anlegen = await gatewayWith(stubFetch(409, null, [])).planWorksiteDay(ANLEGEN, { + idempotencyKey: KEY_A, + }); + expect(anlegen.ok).toBe(false); + if (anlegen.ok) return; + expect(anlegen.failure).toBe("REJECTED"); + + const wechsel = await gatewayWith(stubFetch(409, null, [])).updateWorksiteDayTeam(TEAMWECHSEL, { + idempotencyKey: KEY_A, + }); + expect(wechsel.ok).toBe(false); + if (wechsel.ok) return; + expect(wechsel.failure).toBe("STALE_VERSION"); + }); + + it("vertraut einer formal falschen Erfolgsantwort NICHT", async () => { + // Dieselbe Zusicherung wie fuer das Fenster, jetzt fuer den Tagesvertrag: + // ein 200 mit fremdem Rumpf ist ein gebrochener Vertrag, keine Nutzlast. + const kaputt = { ...BAUSTELLENTAG, lockVersion: -1 }; + const result = await gatewayWith(stubFetch(200, kaputt, [])).updateWorksiteDayTeam( + TEAMWECHSEL, + { idempotencyKey: KEY_A }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure).toBe("CONTRACT_VIOLATION"); + }); +}); diff --git a/packages/contracts/test/iso-week-key.test.ts b/packages/contracts/test/iso-week-key.test.ts index 1975417d..6ea0fb7a 100644 --- a/packages/contracts/test/iso-week-key.test.ts +++ b/packages/contracts/test/iso-week-key.test.ts @@ -114,10 +114,12 @@ import { fileURLToPath } from "node:url"; import { CreateAssignmentCommandSchema, + PlanWorksiteDayCommandSchema, PlanningWindowQuerySchema, PlanningWindowSchema, PublishPlanCommandSchema, PublishedPlanVersionSchema, + UpdateWorksiteDayTeamCommandSchema, ValidatePlanCommandSchema, } from "../src/planning/schemas.js"; @@ -130,6 +132,7 @@ const EMPLOYEE_ID = "00000000-0000-4000-8000-0000004010a1"; const WORKSITE_ID = "00000000-0000-4000-8000-0000005010a1"; const SNAPSHOT_ID = "00000000-0000-4000-8000-0000007010a1"; const USER_ID = "00000000-0000-4000-8000-0000008010a1"; +const WORKSITE_DAY_ID = "00000000-0000-4000-8000-0000009010a1"; /** Je Schema ein minimal gueltiger Rumpf, in den der Wochenschluessel eingesetzt wird. */ const STELLEN: ReadonlyArray<{ @@ -215,6 +218,31 @@ const STELLEN: ReadonlyArray<{ schema: SelectablePlanVersionSchema, baue: (weekKey) => ({ id: VERSION_ID, weekKey, publishedAt: INSTANT }), }, + { + // EYT-147 M2. Beide Tagescommands tragen die Woche aus demselben Grund wie + // `CreateAssignmentCommandSchema`: sie ist die Woche, die die Planerin + // GEOEFFNET hat, und der Server vergleicht sie mit der, die er selbst aus + // dem Tag ausrechnet. + name: "PlanWorksiteDayCommandSchema", + schema: PlanWorksiteDayCommandSchema, + baue: (weekKey) => ({ + weekKey, + worksiteId: WORKSITE_ID, + localDate: "2026-08-03", + // Mindestens ein Eintrag: ein Baustellentag ohne Team wird nicht angelegt. + team: [{ employeeId: EMPLOYEE_ID, interval: { startUtc: INSTANT, endUtc: INSTANT_SPAETER } }], + }), + }, + { + name: "UpdateWorksiteDayTeamCommandSchema", + schema: UpdateWorksiteDayTeamCommandSchema, + baue: (weekKey) => ({ + weekKey, + worksiteDayId: WORKSITE_DAY_ID, + expectedLockVersion: 0, + team: [{ employeeId: EMPLOYEE_ID, interval: { startUtc: INSTANT, endUtc: INSTANT_SPAETER } }], + }), + }, ]; /** diff --git a/packages/contracts/test/openapi-drift.test.ts b/packages/contracts/test/openapi-drift.test.ts index a8f16f20..df57bab0 100644 --- a/packages/contracts/test/openapi-drift.test.ts +++ b/packages/contracts/test/openapi-drift.test.ts @@ -12,6 +12,8 @@ import { serializeOpenApiDocument, weekKeyParam, } from "../src/openapi/document.js"; +import { WORKSITE_DAY_PROBLEM_TYPE } from "../src/planning/schemas.js"; +import { LocalDateSchema } from "../src/primitives.js"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const committedPath = resolve(packageRoot, "openapi/v1.json"); @@ -271,3 +273,127 @@ describe("Vertragsform", () => { expect(open).toEqual([]); }); }); + +describe("Baustellentag-Routen (EYT-147 M2)", () => { + const doc = buildOpenApiDocument(); + const paths = doc["paths"] as Record>>; + const schemas = (doc["components"] as { schemas: Record> }) + .schemas; + + /** Pfad -> erwartete Operation. Tabelle statt vier Einzelfaelle. */ + const ROUTEN = [ + { pfad: "/planung/baustellentage", operationId: "planWorksiteDay", erfolg: "201" }, + { pfad: "/planung/baustellentage/team", operationId: "updateWorksiteDayTeam", erfolg: "200" }, + ] as const; + + it.each(ROUTEN)("dokumentiert POST $pfad als $operationId", ({ pfad, operationId, erfolg }) => { + const eintrag = paths[pfad]; + expect(eintrag, `Pfad ${pfad} fehlt im Vertrag`).toBeDefined(); + + // Vakanzschutz: ohne diese Zeile waeren die Zusicherungen unten auch dann + // gruen, wenn die Operation gar keine POST-Methode traegt. + const op = eintrag?.["post"]; + expect(op, `${pfad} hat keine POST-Operation`).toBeDefined(); + expect(op?.["operationId"]).toBe(operationId); + + const responses = op?.["responses"] as Record | undefined; + expect(Object.keys(responses ?? {})).toContain(erfolg); + + // Und der Erfolgsfall traegt WIRKLICH den Baustellentag — nicht irgendein + // Objekt. Ohne diese Zeile bliebe der Fall gruen, wenn jemand die Antwort + // versehentlich auf `AssignmentDto` umhaengt. + const erfolgsantwort = responses?.[erfolg] as + { content: { "application/json": { schema: { $ref: string } } } } | undefined; + expect(erfolgsantwort?.content["application/json"].schema.$ref).toBe( + "#/components/schemas/WorksiteDayDto", + ); + }); + + it.each(ROUTEN)("verlangt bei $pfad den Idempotenzschluessel", ({ pfad }) => { + const params = (paths[pfad]?.["post"]?.["parameters"] ?? []) as Array<{ + name: string; + required: boolean; + }>; + const header = params.find((p) => p.name === "Idempotency-Key"); + expect(header, `${pfad} ohne Idempotency-Key`).toBeDefined(); + expect(header?.required).toBe(true); + }); + + it("nennt die neuen Problem-Types im 409-Zweig, aus der Konstante abgeleitet", () => { + // Gemessen wird die KOPPLUNG an `WORKSITE_DAY_PROBLEM_TYPE`, nicht der + // Wortlaut. Schriebe jemand die URNs als Literale in `document.ts`, bliebe + // `v1.json` byteweise gleich und der Drift-Test gruen — die Konstante waere + // dann ein Kommentar, und eine Umbenennung liefe still auseinander. + const anlegen = paths["/planung/baustellentage"]?.["post"]?.["responses"] as + Record | undefined; + expect(anlegen?.["409"]?.description).toContain( + WORKSITE_DAY_PROBLEM_TYPE.DUPLICATE_WORKSITE_DAY, + ); + + const team = paths["/planung/baustellentage/team"]?.["post"]?.["responses"] as + Record | undefined; + expect(team?.["409"]?.description).toContain(WORKSITE_DAY_PROBLEM_TYPE.STALE_WORKSITE_DAY); + expect(team?.["409"]?.description).toContain(WORKSITE_DAY_PROBLEM_TYPE.WORKSITE_DAY_NOT_FOUND); + + // Gegenprobe: der Publish-Typ gehoert NICHT hierher. Er ist eine + // Veroeffentlichungsbedingung, keine Konfliktantwort dieser Routen. + // + // Ebenso wenig `WORKSITE_DAY_TEAM_REQUIRED` (PO-Review 15164): `team.min(1)` + // lehnt das leere Team an der REQUEST-Grenze ab, und der Planungscontroller + // beantwortet jeden fehlgeschlagenen `safeParse` mit 400. Ein + // veroeffentlichter 409 dafuer waere unerreichbar — ein Versprechen, das + // kein Serverpfad einloest. Der Fall unten misst, dass `minItems: 1` genau + // deshalb weiterhin im Dokument steht: die Regel bleibt, nur ihr falscher + // Fehlerzweig faellt weg. + for (const beschreibung of [anlegen?.["409"]?.description, team?.["409"]?.description]) { + expect(beschreibung).not.toContain("worksite-day-incomplete"); + expect(beschreibung).not.toContain(WORKSITE_DAY_PROBLEM_TYPE.WORKSITE_DAY_TEAM_REQUIRED); + } + }); + + it("traegt im Dokument genau die Zod-Grenzen, die JSON Schema ausdruecken KANN", () => { + // ## Warum dieser Fall existiert + // + // Der Ehrlichkeitsabsatz an `NAMED_SCHEMAS` (document.ts) nennt vier Regeln, + // die bei der Erzeugung wegfallen, und drei, die ankommen. Bis hierher war + // beides Prosa. Dieser Fall misst die Haelfte, die ANKOMMEN muss — und + // damit haben die Gegenmutationen "eine Id entfernen" und "team.min(1) + // entfernen" neben dem Laufzeittest einen ZWEITEN, unabhaengigen Detektor. + + // (1) Stabile Identitaet und Revision sind ZWEI Pflichtfelder — ein + // Generator sieht sie getrennt, keines ist optional oder abgeleitet. + const dto = schemas["WorksiteDayDto"]; + expect(dto, "Komponente WorksiteDayDto fehlt — der Rest misst nichts").toBeDefined(); + expect(dto?.["required"]).toEqual( + expect.arrayContaining(["worksiteDayId", "configurationId", "lockVersion"]), + ); + expect(dto?.["additionalProperties"]).toBe(false); + + // (2) `team.min(1)` kommt als `minItems: 1` an — in BEIDEN Kommandos. + for (const name of ["PlanWorksiteDayCommand", "UpdateWorksiteDayTeamCommand"] as const) { + const team = ( + schemas[name]?.["properties"] as Record> | undefined + )?.["team"]; + expect(team, `${name}.team fehlt`).toBeDefined(); + expect(team?.["minItems"], `${name}.team ohne minItems — team.min(1) gefallen?`).toBe(1); + } + + // (3) Additiv heisst: NICHT required. `.default([])` fuehrte das Feld unter + // `required` (gemessen zod 4.4.3) und behauptete ein Feld, das der heutige + // Server nie sendet. + const fenster = schemas["PlanningWindow"]; + expect(Object.keys((fenster?.["properties"] ?? {}) as object)).toContain("worksiteDays"); + expect(fenster?.["required"]).not.toContain("worksiteDays"); + + // (4) Und die Ehrlichkeit zur Kalenderregel: `localDate` traegt im Dokument + // nur das Muster — `2026-02-30` geht dort durch, zur Laufzeit nicht. + // Beide Haelften gemessen, wie beim Kostenschnappschuss. + const localDate = ( + schemas["PlanWorksiteDayCommand"]?.["properties"] as Record> + )["localDate"]; + expect(typeof localDate?.["pattern"]).toBe("string"); + expect(new RegExp(String(localDate?.["pattern"])).test("2026-02-30")).toBe(true); + expect(localDate?.["format"]).toBeUndefined(); + expect(LocalDateSchema.safeParse("2026-02-30").success).toBe(false); + }); +}); diff --git a/packages/contracts/test/planning-gateway.contract.test.ts b/packages/contracts/test/planning-gateway.contract.test.ts index 9f0dd4b0..7a7bbaa1 100644 --- a/packages/contracts/test/planning-gateway.contract.test.ts +++ b/packages/contracts/test/planning-gateway.contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { MockPlanningGateway } from "../src/mock/planning.js"; +import { WorksiteDayDtoSchema } from "../src/planning/schemas.js"; import { PLANNING_GATEWAY_CONTRACT_FIXTURE, PLANNING_VALIDATION_CODE, @@ -87,6 +88,20 @@ function makeMock(): MockPlanningGateway { } : { conflicts: [], publishable: true }, nextAssignmentId: "3f2504e0-4f89-41d3-9a0c-0305e82c3304", + // EYT-147 M2: Antwortform der Tagescommands. Die Ids stehen hier und nicht + // im Mock, damit keine Zusicherung gegen eine eingebaute Konstante prueft. + nextWorksiteDay: { + worksiteDayId: "3f2504e0-4f89-41d3-9a0c-0305e82c3305", + // Die Baustelle des Tages — bewusst die BEKANNTE aus `resources`, damit eine + // Antwort, die statt dessen eine der beiden anderen Ids traegt, auffliegt. + worksiteId: WORKSITE_ID, + localDate: "2026-08-03", + configurationId: "3f2504e0-4f89-41d3-9a0c-0305e82c3306", + assignmentIds: [ + "3f2504e0-4f89-41d3-9a0c-0305e82c3307", + "3f2504e0-4f89-41d3-9a0c-0305e82c3308", + ], + }, }); } @@ -187,3 +202,97 @@ describe("Planversion gilt pro Woche", () => { expect(unknown.failure).toBe("REJECTED"); }); }); + +describe("Baustellentag-Kommandos des Mocks (EYT-147 M2)", () => { + const SCHLUESSEL = PLANNING_GATEWAY_CONTRACT_FIXTURE.idempotencyKey; + const TEAM = [ + { + employeeId: EMPLOYEE_ID, + interval: { startUtc: "2026-08-03T06:00:00.000Z", endUtc: "2026-08-03T10:00:00.000Z" }, + }, + ]; + + it("liefert beim Anlegen eine vertragskonforme Antwort mit getrennten Ids", async () => { + const result = await makeMock().planWorksiteDay( + { weekKey: "2026-W32", worksiteId: WORKSITE_ID, localDate: "2026-08-03", team: TEAM }, + { idempotencyKey: SCHLUESSEL }, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + // Gegen das VEROEFFENTLICHTE Schema geparst, nicht nur auf Felder geprueft: + // eine Attrappe, die etwas anderes liefert, faellt hier durch. + expect(() => WorksiteDayDtoSchema.parse(result.value)).not.toThrow(); + expect(result.value.worksiteDayId).not.toBe(result.value.configurationId); + // Dieselbe Dreierprobe wie beim Aendern: die Baustelle stammt aus dem + // KOMMANDO, die beiden anderen Ids aus dem Fixture. + expect(result.value.worksiteId).toBe(WORKSITE_ID); + expect(result.value.worksiteDayId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3305"); + expect(result.value.configurationId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3306"); + expect(result.value.lockVersion).toBe(0); + expect(result.value.team).toHaveLength(1); + expect(result.value.team[0]?.employeeId).toBe(EMPLOYEE_ID); + // Die Assignment-Id kommt aus dem Fixture — der Mock erfindet keine. + expect(result.value.team[0]?.assignmentId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3307"); + }); + + it("schreibt beim Ersetzen der Besetzung die lockVersion fort", async () => { + const result = await makeMock().updateWorksiteDayTeam( + { + weekKey: "2026-W32", + worksiteDayId: "3f2504e0-4f89-41d3-9a0c-0305e82c3305", + expectedLockVersion: 3, + team: TEAM, + }, + { idempotencyKey: SCHLUESSEL }, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(() => WorksiteDayDtoSchema.parse(result.value)).not.toThrow(); + // Ohne Fortschreibung muesste der Client fuer jede zweite Aenderung neu + // laden — genau die Luecke, in der der veraltete Stand entsteht. + expect(result.value.lockVersion).toBe(4); + + // ## Warum die drei Ids EINZELN zugesichert werden + // + // `WorksiteDayDtoSchema.parse` kann sie nicht auseinanderhalten: alle drei + // sind `IdSchema`, also bestaetigt ein Rundlauf jede Belegung. Drei getrennte + // Zusicherungen auf drei verschiedene Fixturewerte sind die einzige Form, die + // einen Tausch nicht ueberlebt. + expect(result.value.worksiteId).toBe(WORKSITE_ID); + expect(result.value.worksiteDayId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3305"); + expect(result.value.configurationId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3306"); + expect( + new Set([result.value.worksiteId, result.value.worksiteDayId, result.value.configurationId]) + .size, + ).toBe(3); + }); + + it("lehnt ein leeres Team ab, statt einen leeren Tag anzulegen", async () => { + const result = await makeMock().planWorksiteDay( + { weekKey: "2026-W32", worksiteId: WORKSITE_ID, localDate: "2026-08-03", team: [] }, + { idempotencyKey: SCHLUESSEL }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure).toBe("REJECTED"); + }); + + it("erfindet keine Assignment-Id, wenn das Fixture nicht reicht", async () => { + // Der Mock haelt zwei Ids bereit. Ein drittes Teammitglied muss auffallen — + // eine still abgeschnittene Besetzung waere die schlimmere Antwort. + const zuGross = [0, 1, 2].map((tag) => ({ + employeeId: EMPLOYEE_ID, + interval: { + startUtc: `2026-08-0${3 + tag}T06:00:00.000Z`, + endUtc: `2026-08-0${3 + tag}T10:00:00.000Z`, + }, + })); + const result = await makeMock().planWorksiteDay( + { weekKey: "2026-W32", worksiteId: WORKSITE_ID, localDate: "2026-08-03", team: zuGross }, + { idempotencyKey: SCHLUESSEL }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.problem?.detail).toContain("Fixture erweitern"); + }); +}); diff --git a/packages/contracts/test/primitives.test.ts b/packages/contracts/test/primitives.test.ts index ee78c6f6..ada35936 100644 --- a/packages/contracts/test/primitives.test.ts +++ b/packages/contracts/test/primitives.test.ts @@ -5,7 +5,12 @@ import { PlanningWindowSchema, TimeIntervalDtoSchema, } from "../src/planning/schemas.js"; -import { IdempotencyKeySchema, InstantSchema, ProblemDocumentSchema } from "../src/primitives.js"; +import { + IdempotencyKeySchema, + InstantSchema, + LocalDateSchema, + ProblemDocumentSchema, +} from "../src/primitives.js"; describe("InstantSchema — genau eine Schreibweise", () => { it("nimmt die kanonische Form an", () => { @@ -119,3 +124,23 @@ describe("TimeIntervalDto erzwingt die Reihenfolge", () => { expect(TimeIntervalDtoSchema.safeParse(ok).success).toBe(true); }); }); + +describe("LocalDateSchema — ein realer Kalendertag, keine Uhrzeit, keine Zone", () => { + it.each(["2026-08-03", "2024-02-29", "2026-12-31"])("nimmt %s an", (tag) => { + expect(LocalDateSchema.safeParse(tag).success).toBe(true); + }); + + it("lehnt einen syntaktisch gueltigen, kalendarisch unmoeglichen Tag ab", () => { + // Der Pflichtfall. Ein reines Muster laesst ihn durch — genau die Luecke, + // die `BusinessDateSchema` im Kostenbereich bis heute hat. `worksite_days.local_date` + // ist vom Typ `date`; die Datenbank haette die Zeile abgelehnt. + expect(LocalDateSchema.safeParse("2026-02-30").success).toBe(false); + }); + + it.each(["2025-02-29", "2026-13-01", "2026-00-10", "2026-04-31", "2026-8-3", "03.08.2026", ""])( + "lehnt %s ab", + (tag) => { + expect(LocalDateSchema.safeParse(tag).success).toBe(false); + }, + ); +}); diff --git a/packages/contracts/test/public-surface.test.ts b/packages/contracts/test/public-surface.test.ts index de4281e0..1d0b3c44 100644 --- a/packages/contracts/test/public-surface.test.ts +++ b/packages/contracts/test/public-surface.test.ts @@ -40,4 +40,21 @@ describe("oeffentliche Oberflaeche von @easytree/contracts", () => { expect(names).toContain("IDEMPOTENCY_HEADER"); expect(names).toContain("ProblemDocumentSchema"); }); + + it("fuehrt die WorksiteDay-Bausteine von EYT-147 M2 namentlich", () => { + // Ein vergessener Export fiele sonst erst in apps/web auf — als Typfehler in + // einem anderen Paket, weit weg von der Ursache. + for (const name of [ + "LocalDateSchema", + "WorksiteDayTeamEntrySchema", + "WorksiteDayTeamCommandSchema", + "WorksiteDayTeamMemberSchema", + "WorksiteDayDtoSchema", + "PlanWorksiteDayCommandSchema", + "UpdateWorksiteDayTeamCommandSchema", + "WORKSITE_DAY_PROBLEM_TYPE", + ]) { + expect(names, `${name} fehlt in src/index.ts`).toContain(name); + } + }); }); diff --git a/packages/contracts/test/worksite-day-schemas.test.ts b/packages/contracts/test/worksite-day-schemas.test.ts new file mode 100644 index 00000000..e124818a --- /dev/null +++ b/packages/contracts/test/worksite-day-schemas.test.ts @@ -0,0 +1,627 @@ +/** + * Die WorksiteDay-Transportgrenze (EYT-147, Meilenstein M2, Jira EYT-151). + * + * ## Was hier gemessen wird — und was ausdruecklich nicht + * + * Nur der VERTRAG. M2 baut keine Businesslogik: es gibt keinen Server, der + * einen Baustellentag anlegt, und keine Repository-Projektion, die + * `worksiteDays` fuellt. Was diese Datei belegt, ist die Form, die beide Seiten + * spaeter einhalten muessen — Schemata, Regeln, Asymmetrien. + * + * ## Die Asymmetrie, die keine Nachlaessigkeit ist + * + * `team: []` ist im KOMMANDO verboten und in der ANTWORT erlaubt. Confluence + * 41484289 §2 Nr. 8 verlangt das erste ("ein leerer Baustellentag wird nicht + * still erzeugt oder veroeffentlicht"). Das zweite folgt aus der Gegenrichtung: + * ein Antwortschema, das den leeren Tag verbietet, macht aus einer einzelnen + * unvollstaendigen Zeile eine unlesbare GESAMTE Planungsantwort — der Client + * verwirft dann das ganze Fenster. Wer die beiden Seiten vereinheitlicht, + * bricht genau eine der zwei Zusagen. + */ +import { describe, expect, it } from "vitest"; + +import { + CONFLICT_CODE_VALUES, + PlanningWindowSchema, + PlanWorksiteDayCommandSchema, + UpdateWorksiteDayTeamCommandSchema, + WORKSITE_DAY_PROBLEM_TYPE, + WorksiteDayDtoSchema, + WorksiteDayTeamCommandSchema, + WorksiteDayTeamEntrySchema, + WorksiteDayTeamMemberSchema, +} from "../src/planning/schemas.js"; + +const WORKSITE_DAY_ID = "00000000-0000-4000-8000-00000000d001"; +const CONFIGURATION_ID = "00000000-0000-4000-8000-00000000c001"; +const WORKSITE_ID = "00000000-0000-4000-8000-00000000a001"; +/** + * Eine ZWEITE, ebenfalls bekannte Baustelle. + * + * Nicht dasselbe wie {@link WORKSITE_FREMD}: die ist unbekannt und faellt schon + * an der Aufloesbarkeit. Fuer die Uebereinstimmungsregel braucht es zwei Namen, + * die beide in `resources.worksites` stehen — sonst misst der Fall die + * Aufloesbarkeit und nicht den Vergleich. + */ +const WORKSITE_ZWEI = "00000000-0000-4000-8000-00000000a002"; +const WORKSITE_FREMD = "00000000-0000-4000-8000-00000000a999"; +const PERSON_A = "00000000-0000-4000-8000-0000000000a1"; +const PERSON_B = "00000000-0000-4000-8000-0000000000b1"; +const EINSATZ_1 = "00000000-0000-4000-8000-0000000000e1"; +const EINSATZ_2 = "00000000-0000-4000-8000-0000000000e2"; +const ENTWURF_ID = "00000000-0000-4000-8000-00000000f001"; + +/** Halb-offen, wie `TimeIntervalDtoSchema` es verlangt. */ +const FRUEH = { startUtc: "2026-08-03T06:00:00.000Z", endUtc: "2026-08-03T10:00:00.000Z" }; +const SPAET = { startUtc: "2026-08-03T12:00:00.000Z", endUtc: "2026-08-03T16:00:00.000Z" }; +/** Ueberschneidet FRUEH um eine Stunde. */ +const UEBERLAPPEND = { startUtc: "2026-08-03T09:00:00.000Z", endUtc: "2026-08-03T11:00:00.000Z" }; + +const WOCHE = "2026-W32"; +const TAG = "2026-08-03"; + +function baustellentag(team: unknown = []): Record { + return { + worksiteDayId: WORKSITE_DAY_ID, + configurationId: CONFIGURATION_ID, + worksiteId: WORKSITE_ID, + localDate: TAG, + lockVersion: 0, + team, + }; +} + +function kommando(team: unknown): unknown { + return { weekKey: WOCHE, worksiteId: WORKSITE_ID, localDate: TAG, team }; +} + +function teamAenderung(team: unknown): unknown { + return { weekKey: WOCHE, worksiteDayId: WORKSITE_DAY_ID, expectedLockVersion: 0, team }; +} + +describe("WorksiteDayTeamCommand — Regeln eines Kommandoteams", () => { + it("nimmt einen einzelnen Eintrag an", () => { + expect( + WorksiteDayTeamEntrySchema.safeParse({ employeeId: PERSON_A, interval: FRUEH }).success, + ).toBe(true); + expect( + WorksiteDayTeamCommandSchema.safeParse([{ employeeId: PERSON_A, interval: FRUEH }]).success, + ).toBe(true); + }); + + it("lehnt ein verkehrtes Intervall im Eintrag ab — dieselbe Regel wie ueberall", () => { + // `TimeIntervalDtoSchema` verlangt endUtc > startUtc; der Teameintrag erbt + // die Regel, statt sie nachzubauen. Ohne diesen Fall koennte jemand hier ein + // eigenes, lockereres Intervallschema einsetzen, und niemand merkte es. + const verkehrt = { startUtc: FRUEH.endUtc, endUtc: FRUEH.startUtc }; + expect( + WorksiteDayTeamCommandSchema.safeParse([{ employeeId: PERSON_A, interval: verkehrt }]) + .success, + ).toBe(false); + }); + + it("lehnt ein identisches (employeeId, interval)-Paar doppelt ab — als DUPLIKAT benannt", () => { + const ergebnis = WorksiteDayTeamCommandSchema.safeParse([ + { employeeId: PERSON_A, interval: FRUEH }, + { employeeId: PERSON_A, interval: { ...FRUEH } }, + ]); + expect(ergebnis.success).toBe(false); + if (ergebnis.success) return; + + // ## Warum die MELDUNG geprueft wird und nicht nur `success` + // + // Ein identisches Paar ueberlappt sich selbst. Ein Fall, der nur `success` + // zusichert, bleibt deshalb auch dann rot, wenn man den Duplikatzweig + // ersatzlos loescht — die Ueberlappungsregel faengt ihn dann. Der Test waere + // gruen, die Regel weg, und die Planerin suchte nach einer Zeitkollision, wo + // sie zweimal dieselbe Zeile abgeschickt hat. Erst die Meldung trennt die + // beiden Regeln, und erst damit gibt es eine Gegenmutation, die feuert. + const meldungen = ergebnis.error.issues.map((i) => i.message).join(" | "); + expect(meldungen).toContain("zweimal im Team"); + expect(meldungen).not.toContain("ueberlappen sich"); + }); + + it("lehnt ueberlappende Intervalle derselben Person ab — als UEBERLAPPUNG benannt", () => { + const ergebnis = WorksiteDayTeamCommandSchema.safeParse([ + { employeeId: PERSON_A, interval: FRUEH }, + { employeeId: PERSON_A, interval: UEBERLAPPEND }, + ]); + expect(ergebnis.success).toBe(false); + if (ergebnis.success) return; + // Gegenstueck zum Duplikatfall: hier muss GENAU die andere Meldung stehen. + // Zusammen zwingen die beiden Faelle die zwei Regeln auseinander. + const meldungen = ergebnis.error.issues.map((i) => i.message).join(" | "); + expect(meldungen).toContain("ueberlappen sich"); + expect(meldungen).not.toContain("zweimal im Team"); + }); + + it("nimmt zwei getrennte, nicht ueberlappende Intervalle derselben Person an", () => { + // Der Fall, den die Ueberlappungsregel NICHT verschlucken darf: eine Person + // kann vormittags und nachmittags auf derselben Baustelle stehen + // (Confluence 41484289 §2 Nr. 4). + expect( + WorksiteDayTeamCommandSchema.safeParse([ + { employeeId: PERSON_A, interval: FRUEH }, + { employeeId: PERSON_A, interval: SPAET }, + ]).success, + ).toBe(true); + }); + + it("nimmt mehrere unterschiedliche Teammitglieder an", () => { + expect( + WorksiteDayTeamCommandSchema.safeParse([ + { employeeId: PERSON_A, interval: FRUEH }, + { employeeId: PERSON_B, interval: FRUEH }, + ]).success, + ).toBe(true); + }); + + it("bezieht die Ueberlappungsregel auf die Person, nicht auf den Tag", () => { + // Gegenprobe zur Zeile darueber: waere die Regel personenblind, waere + // dasselbe Intervall fuer zwei verschiedene Personen bereits ein Konflikt. + expect( + WorksiteDayTeamCommandSchema.safeParse([ + { employeeId: PERSON_A, interval: UEBERLAPPEND }, + { employeeId: PERSON_B, interval: FRUEH }, + ]).success, + ).toBe(true); + }); + + it("lehnt ein leeres Team ab — ein Baustellentag entsteht nicht ohne Besetzung", () => { + expect(WorksiteDayTeamCommandSchema.safeParse([]).success).toBe(false); + }); +}); + +describe("WorksiteDayDto", () => { + it("traegt Identitaet und Konfigurationsrevision unabhaengig voneinander", () => { + const dto = WorksiteDayDtoSchema.parse(baustellentag()); + expect(dto.worksiteDayId).toBe(WORKSITE_DAY_ID); + expect(dto.configurationId).toBe(CONFIGURATION_ID); + expect(dto.worksiteDayId).not.toBe(dto.configurationId); + + // Die eigentliche Aussage: vertauscht ergibt ein ANDERES Objekt. Waere eins + // ein Alias des anderen, waeren beide Ergebnisse gleich. + const vertauscht = WorksiteDayDtoSchema.parse({ + ...baustellentag(), + worksiteDayId: CONFIGURATION_ID, + configurationId: WORKSITE_DAY_ID, + }); + expect(vertauscht).not.toEqual(dto); + expect(vertauscht.worksiteDayId).toBe(CONFIGURATION_ID); + }); + + it("verlangt BEIDE Ids — ohne Revision ist der Tag nicht adressierbar, ohne Identitaet nicht wiedererkennbar", () => { + const ohneRevision = Object.fromEntries( + Object.entries(baustellentag()).filter(([k]) => k !== "configurationId"), + ); + const ohneIdentitaet = Object.fromEntries( + Object.entries(baustellentag()).filter(([k]) => k !== "worksiteDayId"), + ); + expect(WorksiteDayDtoSchema.safeParse(ohneRevision).success).toBe(false); + expect(WorksiteDayDtoSchema.safeParse(ohneIdentitaet).success).toBe(false); + }); + + it("nimmt ein leeres Team an", () => { + expect(WorksiteDayDtoSchema.safeParse(baustellentag([])).success).toBe(true); + }); + + it("nimmt ein Teammitglied mit Assignment-Id an", () => { + const mitglied = { assignmentId: EINSATZ_1, employeeId: PERSON_A, interval: FRUEH }; + expect(WorksiteDayTeamMemberSchema.safeParse(mitglied).success).toBe(true); + expect(WorksiteDayDtoSchema.safeParse(baustellentag([mitglied])).success).toBe(true); + }); + + it("verlangt lockVersion als ganze Zahl ab null", () => { + expect(WorksiteDayDtoSchema.safeParse({ ...baustellentag(), lockVersion: 0 }).success).toBe( + true, + ); + expect(WorksiteDayDtoSchema.safeParse({ ...baustellentag(), lockVersion: -1 }).success).toBe( + false, + ); + expect(WorksiteDayDtoSchema.safeParse({ ...baustellentag(), lockVersion: 1.5 }).success).toBe( + false, + ); + }); + + it("lehnt einen kalendarisch unmoeglichen localDate ab", () => { + expect( + WorksiteDayDtoSchema.safeParse({ ...baustellentag(), localDate: "2026-02-30" }).success, + ).toBe(false); + }); +}); + +describe("Kommando/Antwort-Asymmetrie beim leeren Team", () => { + it("lehnt team: [] im PlanWorksiteDayCommand ab", () => { + expect(PlanWorksiteDayCommandSchema.safeParse(kommando([])).success).toBe(false); + }); + + it("lehnt team: [] im UpdateWorksiteDayTeamCommand ab", () => { + expect(UpdateWorksiteDayTeamCommandSchema.safeParse(teamAenderung([])).success).toBe(false); + }); + + it("nimmt team: [] im WorksiteDayDto an", () => { + expect(WorksiteDayDtoSchema.safeParse(baustellentag([])).success).toBe(true); + }); +}); + +describe("UpdateWorksiteDayTeamCommand adressiert die stabile Identitaet", () => { + it("nimmt weekKey, worksiteDayId, expectedLockVersion und team an", () => { + const ergebnis = UpdateWorksiteDayTeamCommandSchema.safeParse( + teamAenderung([{ employeeId: PERSON_A, interval: FRUEH }]), + ); + expect(ergebnis.success).toBe(true); + }); + + it("kennt weder configurationId noch localDate", () => { + // Beide waeren stille Zweitwahrheiten: die Revision ist durch weekKey + // eindeutig (hoechstens ein Draft je Woche, 41484289 §4), der Stand durch + // expectedLockVersion, der Tag steht an der Identitaet. Ein Kommando, das + // sie mitschickt, koennte ihnen widersprechen. + const basis = teamAenderung([{ employeeId: PERSON_A, interval: FRUEH }]) as Record< + string, + unknown + >; + expect( + UpdateWorksiteDayTeamCommandSchema.safeParse({ ...basis, configurationId: CONFIGURATION_ID }) + .success, + ).toBe(false); + expect(UpdateWorksiteDayTeamCommandSchema.safeParse({ ...basis, localDate: TAG }).success).toBe( + false, + ); + }); + + it("verlangt expectedLockVersion als ganze Zahl ab null", () => { + const team = [{ employeeId: PERSON_A, interval: FRUEH }]; + for (const wert of [-1, 1.5, "0", null, undefined]) { + expect( + UpdateWorksiteDayTeamCommandSchema.safeParse({ + ...(teamAenderung(team) as Record), + expectedLockVersion: wert, + }).success, + `expectedLockVersion ${JSON.stringify(wert)} wurde angenommen`, + ).toBe(false); + } + }); + + it("wendet dieselben Teamregeln an wie das Anlegekommando", () => { + expect( + UpdateWorksiteDayTeamCommandSchema.safeParse( + teamAenderung([ + { employeeId: PERSON_A, interval: FRUEH }, + { employeeId: PERSON_A, interval: UEBERLAPPEND }, + ]), + ).success, + ).toBe(false); + }); +}); + +describe("Striktheit der neuen Schemata", () => { + /** + * ## Warum das ein eigener Fall ist und nicht der Drift-Test genuegt + * + * `openapi-drift.test.ts` sichert zu, dass jedes Antwortobjekt im erzeugten + * Dokument `additionalProperties: false` traegt. Fuer DIESE Eigenschaft misst + * das nichts: gemessen mit zod 4.4.3 gibt `z.toJSONSchema` fuer `z.object` und + * `z.strictObject` dasselbe `additionalProperties: false` aus. Der Unterschied + * existiert nur zur LAUFZEIT — `z.object` entfernt einen unbekannten Schluessel + * still und meldet Erfolg. Faellt eines dieser Schemata auf `z.object` zurueck, + * bliebe `v1.json` byteweise gleich und der Drift-Test gruen, waehrend ein + * undokumentiertes Feld durch die Leitung ginge. + * + * Geprueft wird deshalb das Verhalten, tabellengetrieben, damit ein spaeter + * hinzugefuegtes Schema als fehlende Zeile auffaellt und nicht als Luecke. + */ + const STRENGE: ReadonlyArray<{ + readonly name: string; + readonly schema: { safeParse: (wert: unknown) => { success: boolean } }; + readonly gueltig: () => Record; + }> = [ + { + name: "WorksiteDayTeamEntrySchema", + schema: WorksiteDayTeamEntrySchema, + gueltig: () => ({ employeeId: PERSON_A, interval: FRUEH }), + }, + { + name: "WorksiteDayTeamMemberSchema", + schema: WorksiteDayTeamMemberSchema, + gueltig: () => ({ assignmentId: EINSATZ_1, employeeId: PERSON_A, interval: FRUEH }), + }, + { + name: "WorksiteDayDtoSchema", + schema: WorksiteDayDtoSchema, + gueltig: () => baustellentag(), + }, + { + name: "PlanWorksiteDayCommandSchema", + schema: PlanWorksiteDayCommandSchema, + gueltig: () => + kommando([{ employeeId: PERSON_A, interval: FRUEH }]) as Record, + }, + { + name: "UpdateWorksiteDayTeamCommandSchema", + schema: UpdateWorksiteDayTeamCommandSchema, + gueltig: () => + teamAenderung([{ employeeId: PERSON_A, interval: FRUEH }]) as Record, + }, + ]; + + it("deckt jedes neue Objektschema ab — sonst misst diese Tabelle zu wenig", () => { + // Vakanzschutz: ohne diese Zeile waere die Tabelle auch dann gruen, wenn sie + // versehentlich leer liefe oder ein Schema stillschweigend herausfiele. + expect(STRENGE.map((s) => s.name)).toEqual([ + "WorksiteDayTeamEntrySchema", + "WorksiteDayTeamMemberSchema", + "WorksiteDayDtoSchema", + "PlanWorksiteDayCommandSchema", + "UpdateWorksiteDayTeamCommandSchema", + ]); + }); + + for (const eintrag of STRENGE) { + it(`${eintrag.name} nimmt seinen gueltigen Rumpf an und lehnt einen Fremdschluessel ab`, () => { + // Beide Richtungen. Ohne die positive Haelfte waere die negative auch dann + // gruen, wenn der Rumpf aus einem ganz anderen Grund durchfaellt. + expect(eintrag.schema.safeParse(eintrag.gueltig()).success).toBe(true); + expect( + eintrag.schema.safeParse({ ...eintrag.gueltig(), fremdesFeld: "geheim" }).success, + `${eintrag.name} verschluckt unbekannte Schluessel — z.object statt z.strictObject?`, + ).toBe(false); + }); + } +}); + +describe("WORKSITE_DAY_PROBLEM_TYPE — Vokabular der Tagescommands", () => { + it("folgt dem URN-Muster der Planung und traegt genau die fuenf M3/M4-Faelle", () => { + // Dasselbe Muster wie PLANNING_ERROR_TYPE in apps/api + // (planning-problem.filter.ts): urn:easytree:planning:. M3/M4 IMPORTIEREN + // diese Konstante, statt die Zeichenketten erneut zu tippen. + expect(Object.keys(WORKSITE_DAY_PROBLEM_TYPE).sort()).toEqual([ + "DUPLICATE_WORKSITE_DAY", + "INTERVAL_OUTSIDE_DAY", + "STALE_WORKSITE_DAY", + "WORKSITE_DAY_NOT_FOUND", + "WORKSITE_DAY_TEAM_REQUIRED", + ]); + for (const urn of Object.values(WORKSITE_DAY_PROBLEM_TYPE)) { + expect(urn).toMatch(/^urn:easytree:planning:[a-z-]+$/); + } + expect(new Set(Object.values(WORKSITE_DAY_PROBLEM_TYPE)).size).toBe(5); + }); + + it("fuehrt WORKSITE_DAY_INCOMPLETE NICHT — das ist ein reiner Publish-Typ (M5)", () => { + // Ein unvollstaendiger Baustellentag ist beim Lesen und Bearbeiten voellig in + // Ordnung und wird erst beim Veroeffentlichen zum Hindernis. Stuende er in + // `CONFLICT_CODE_VALUES`, veroeffentlichte `v1.json` ihn als gewoehnlichen + // Konfliktcode und ein Client duerfte ihn wie eine Ueberlappung behandeln. + const alles = [...CONFLICT_CODE_VALUES, ...Object.keys(WORKSITE_DAY_PROBLEM_TYPE)].join(" "); + expect(alles).not.toContain("WORKSITE_DAY_INCOMPLETE"); + expect(Object.values(WORKSITE_DAY_PROBLEM_TYPE).join(" ")).not.toContain( + "worksite-day-incomplete", + ); + }); + + it("laesst die bestehende Konfliktcodeliste unveraendert", () => { + // Gegenprobe zur Zeile darueber: ohne sie waere das Verbot auch dann gruen, + // wenn jemand die Liste komplett leerte. + expect([...CONFLICT_CODE_VALUES]).toEqual([ + "EMPLOYEE_INTERVAL_OVERLAP", + "EMPLOYEE_WEEKLY_CAPACITY", + "EMPLOYEE_INACTIVE", + "WORKSITE_NOT_PUBLISHABLE", + ]); + }); +}); + +/** Ein Fenster, das ohne die WorksiteDay-Erweiterung schon gueltig ist. */ +function fenster(zusatz: Record = {}): Record { + return { + weekKey: WOCHE, + timeZone: "Europe/Berlin", + assignments: [ + { id: EINSATZ_1, employeeId: PERSON_A, worksiteId: WORKSITE_ID, interval: FRUEH }, + { id: EINSATZ_2, employeeId: PERSON_B, worksiteId: WORKSITE_ID, interval: SPAET }, + ], + sourceVersion: { id: ENTWURF_ID, state: "draft" }, + publishedVersionId: null, + resources: { + employees: [ + { id: PERSON_A, label: "Anna", active: true }, + { id: PERSON_B, label: "Bruno", active: true }, + ], + worksites: [{ id: WORKSITE_ID, label: "Baustelle Nord", active: true }], + }, + ...zusatz, + }; +} + +function tagImFenster(team: unknown): Record { + return { ...baustellentag(team), worksiteId: WORKSITE_ID }; +} + +const MITGLIED_1 = { assignmentId: EINSATZ_1, employeeId: PERSON_A, interval: FRUEH }; +const MITGLIED_2 = { assignmentId: EINSATZ_2, employeeId: PERSON_B, interval: SPAET }; + +describe("PlanningWindow.worksiteDays — additive Projektion", () => { + it("bleibt ohne das Feld gueltig: Legacy-Assignments ohne Baustellentag", () => { + // Die Kernzusage der Additivitaet. Der heutige Server sendet `worksiteDays` + // nicht; seine Antwort muss weiterhin parsen. + expect(PlanningWindowSchema.safeParse(fenster()).success).toBe(true); + }); + + it("nimmt ein vollstaendiges Fenster mit Assignments UND Baustellentagen an", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([MITGLIED_1, MITGLIED_2])] }), + ); + expect(ergebnis.success).toBe(true); + }); + + it("nimmt ein leeres worksiteDays-Array an", () => { + expect(PlanningWindowSchema.safeParse(fenster({ worksiteDays: [] })).success).toBe(true); + }); + + it("lehnt einen Baustellentag auf einer unbekannten Baustelle ab", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ + worksiteDays: [{ ...tagImFenster([MITGLIED_1]), worksiteId: WORKSITE_FREMD }], + }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("lehnt eine unbekannte assignmentId im Team ab", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ + worksiteDays: [ + tagImFenster([{ ...MITGLIED_1, assignmentId: "00000000-0000-4000-8000-0000000000ff" }]), + ], + }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("lehnt ein Teammitglied ab, dessen employeeId dem Assignment widerspricht", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([{ ...MITGLIED_1, employeeId: PERSON_B }])] }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("lehnt ein Teammitglied ab, dessen Intervall dem Assignment widerspricht", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([{ ...MITGLIED_1, interval: SPAET }])] }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("lehnt dieselbe Assignment-Id in zwei Baustellentagen ab", () => { + const zweiterTag = { + ...tagImFenster([MITGLIED_1]), + worksiteDayId: "00000000-0000-4000-8000-00000000d002", + configurationId: "00000000-0000-4000-8000-00000000c002", + localDate: "2026-08-04", + }; + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([MITGLIED_1]), zweiterTag] }), + ); + expect(ergebnis.success).toBe(false); + }); + + /** + * Ein Fenster, in dem BEIDE Baustellen bekannt sind. + * + * `einsatzBaustelle` entscheidet, auf welcher Baustelle `EINSATZ_1` liegt. + * Der Baustellentag zeigt unveraendert auf {@link WORKSITE_ID} — damit ist + * die Uebereinstimmung die EINZIGE Groesse, die sich zwischen den beiden + * Richtungen dieses Falls unterscheidet. + */ + function fensterMitZweiBaustellen(einsatzBaustelle: string): Record { + return fenster({ + assignments: [ + { id: EINSATZ_1, employeeId: PERSON_A, worksiteId: einsatzBaustelle, interval: FRUEH }, + { id: EINSATZ_2, employeeId: PERSON_B, worksiteId: WORKSITE_ID, interval: SPAET }, + ], + resources: { + employees: [ + { id: PERSON_A, label: "Anna", active: true }, + { id: PERSON_B, label: "Bruno", active: true }, + ], + worksites: [ + { id: WORKSITE_ID, label: "Baustelle Nord", active: true }, + { id: WORKSITE_ZWEI, label: "Baustelle Sued", active: true }, + ], + }, + worksiteDays: [tagImFenster([MITGLIED_1])], + }); + } + + it("lehnt ein Teammitglied ab, dessen Zuweisung auf einer ANDEREN Baustelle liegt", () => { + // PO-Review 15164, Important 1. Person, Intervall und Assignment-Id stimmen + // vollstaendig ueberein — nur die Baustelle nicht. Ohne diese Regel passiert + // ein fachlich widerspruechlicher Payload die Runtime-Validation: die + // Tageskarte zeigt die Person auf Baustelle Nord, die Einsatzliste auf + // Baustelle Sued, und beide Ansichten sehen fuer sich richtig aus. + const ergebnis = PlanningWindowSchema.safeParse(fensterMitZweiBaustellen(WORKSITE_ZWEI)); + expect(ergebnis.success).toBe(false); + }); + + it("nimmt dasselbe Fenster an, sobald Zuweisung und Tag dieselbe Baustelle nennen", () => { + // Gegenprobe zur Zeile darueber. Ohne sie waere der negative Fall auch dann + // gruen, wenn die zweite Baustelle aus einem ganz anderen Grund durchfiele. + const ergebnis = PlanningWindowSchema.safeParse(fensterMitZweiBaustellen(WORKSITE_ID)); + expect(ergebnis.success).toBe(true); + }); + + it("lehnt dieselbe worksiteDayId zweimal als primaere Tageszeile ab", () => { + // PO-Review 15164, Important 2 (A). Die Teams sind ABSICHTLICH verschieden, + // sonst schluege die bestehende Assignment-Einmaligkeit zu und der Fall + // maesse sie statt der Identitaet. + const zweiterTag = { + ...tagImFenster([MITGLIED_2]), + configurationId: "00000000-0000-4000-8000-00000000c002", + localDate: "2026-08-04", + }; + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([MITGLIED_1]), zweiterTag] }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("lehnt dieselbe configurationId zweimal als primaere Tageszeile ab", () => { + // PO-Review 15164, Important 2 (B). Gleiche Bauart, andere Achse: die + // Identitaeten unterscheiden sich, die Revision nicht. + const zweiterTag = { + ...tagImFenster([MITGLIED_2]), + worksiteDayId: "00000000-0000-4000-8000-00000000d002", + localDate: "2026-08-04", + }; + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([MITGLIED_1]), zweiterTag] }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("nimmt zwei Tageszeilen mit verschiedenen Ids an", () => { + // Gegenprobe zu den beiden Zeilen darueber: der Waechter darf nicht jede + // zweite Tageszeile ablehnen, sondern nur die doppelte. + const zweiterTag = { + ...tagImFenster([MITGLIED_2]), + worksiteDayId: "00000000-0000-4000-8000-00000000d002", + configurationId: "00000000-0000-4000-8000-00000000c002", + localDate: "2026-08-04", + }; + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ worksiteDays: [tagImFenster([MITGLIED_1]), zweiterTag] }), + ); + expect(ergebnis.success).toBe(true); + }); + + it("lehnt Baustellentage ohne sourceVersion ab", () => { + const ergebnis = PlanningWindowSchema.safeParse( + fenster({ + assignments: [], + sourceVersion: null, + publishedVersionId: null, + worksiteDays: [tagImFenster([])], + }), + ); + expect(ergebnis.success).toBe(false); + }); + + it("laesst die bestehende Assignment-Projektion unangetastet", () => { + // Gegenprobe: die alten Regeln muessen weiter feuern, sonst haette die + // Erweiterung sie ersetzt statt ergaenzt. + const unaufloesbar = PlanningWindowSchema.safeParse( + fenster({ + assignments: [ + { id: EINSATZ_1, employeeId: PERSON_A, worksiteId: WORKSITE_FREMD, interval: FRUEH }, + ], + }), + ); + expect(unaufloesbar.success).toBe(false); + + const ohneHerkunft = PlanningWindowSchema.safeParse( + fenster({ sourceVersion: null, publishedVersionId: null }), + ); + expect(ohneHerkunft.success).toBe(false); + }); +}); 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;