Skip to content
Merged
25 changes: 25 additions & 0 deletions apps/api/test/architecture-red-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ describe("Rot-Fall", () => {
rmSync(join(root, "apps/api/src/modules/planning"), { recursive: true, force: true });
});

it("faengt einen Werkbank-Import und ein fremdes Paket in der Feld-Shell", () => {
// Der realistischste Verstoss der Zwei-Client-Architektur (EYT-113): eine
// Feld-Datei importiert eine Kosten-Komponente der Werkbank — und ein
// Paket, das nicht auf der Feld-Allowlist steht.
write("apps/web/components/kosten-ansicht.tsx", "export const KostenAnsicht = 1;\n");
write(
"apps/web/app/feld/bad.tsx",
[
'import { KostenAnsicht } from "../../components/kosten-ansicht";',
'import { irgendwas } from "@easytree/domain";',
"export const bad = [KostenAnsicht, irgendwas] as const;",
].join("\n") + "\n",
);
const found = violationsFor().violations.filter((v) => v.rule === "feld-shell-boundary");
expect(
found.some((v) => v.message.includes("apps/web/components/kosten-ansicht")),
"Werkbank-Import nicht erkannt",
).toBe(true);
expect(
found.some((v) => v.message.includes('"@easytree/domain"')),
"fremdes Paket nicht erkannt",
).toBe(true);
rmSync(join(root, "apps/web"), { recursive: true, force: true });
});

it("faengt einen Querimport an der oeffentlichen Modul-API vorbei", () => {
write(
"apps/api/src/modules/planning/application/bypass.ts",
Expand Down
53 changes: 52 additions & 1 deletion apps/api/test/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* Dateien — er belohnt nicht das Anlegen leerer Dateien, um einen Zähler zu heben.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { join, relative, resolve } from "node:path";

import { describe, expect, it } from "vitest";

Expand Down Expand Up @@ -58,6 +58,9 @@ function istGewolltUnaufloesbar(von: string, spezifizierer: string): boolean {
return /^apps\/[^/]+\/cloudflare\/[^/]+\.mjs$/.test(von) && spezifizierer.startsWith("../dist/");
}

/** Die drei Verzeichnisse der Feld-Shell (EYT-113) — eine Liste, zwei Faelle. */
const FELD_PRAEFIXE = ["apps/web/app/feld/", "apps/web/components/feld/", "apps/web/lib/feld/"];

const refs = extractImports(repoRoot, files);
const { violations, scopeCounts } = evaluate(refs);
const starReExports = findStarReExports(repoRoot, files);
Expand Down Expand Up @@ -125,6 +128,54 @@ describe("Architekturgrenzen", () => {
);
});

it("Regel feld-shell-boundary ueberwacht JEDE Datei der Feld-Shell", () => {
// Dieselbe Lehre wie bei ui-dependency-allowlist: `>= 1` unterscheidet
// nicht zwischen "bewacht die Shell" und "bewacht eine Datei". Die linke
// Seite kommt aus den Praefixen ueber die eingesammelten Importe, die
// rechte aus `inScope` der Regel — beide Wege muessen dieselbe Menge
// ergeben. Importfreie Dateien tauchen auf keiner Seite auf; dass der
// SCANNER keine Feld-Datei verschluckt, sichert der naechste Fall.
const ausDemPraefix = new Set(
refs
.filter((ref) => FELD_PRAEFIXE.some((praefix) => ref.from.startsWith(praefix)))
.map((ref) => ref.from),
);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
expect(ausDemPraefix.size).toBeGreaterThan(3);
expect([...(scopeCounts.get("feld-shell-boundary") ?? [])].sort()).toEqual(
[...ausDemPraefix].sort(),
);
});

it("der Collector sammelt JEDE Quelldatei der Feld-Shell von der Platte ein", () => {
// Sourcery-Befund an PR #97: der refs-basierte Mengenvergleich oben ist
// gegenueber Dateien blind, die der Scanner NIE einsammelt — die fehlen
// dann auf beiden Seiten, und der Vergleich bleibt gruen. Deshalb hier
// die unabhaengige Wahrheit: ein eigenes Verzeichnislisting der drei
// Feld-Verzeichnisse (readdirSync, nicht der Collector selbst) muss
// exakt der Menge entsprechen, die `collectSourceFiles` eingesammelt
// hat. Ein SKIP_DIRS-Fehler oder eine vergessene Endung wird damit rot,
// und eine neue importfreie Datei erscheint auf beiden Seiten — sie hat
// zwar nichts zu pruefen (keine Importe, keine moegliche Verletzung),
// aber ihr Einsammeln ist ab jetzt zugesichert.
const QUELL_ENDUNGEN = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
const vonDerPlatte: string[] = [];
for (const wurzel of FELD_PRAEFIXE) {
const absolut = join(repoRoot, wurzel);
if (!existsSync(absolut)) continue;
for (const eintrag of readdirSync(absolut, { recursive: true, withFileTypes: true })) {
if (!eintrag.isFile() || !QUELL_ENDUNGEN.test(eintrag.name)) continue;
vonDerPlatte.push(
relative(repoRoot, join(eintrag.parentPath, eintrag.name)).split("\\").join("/"),
);
}
}
expect(vonDerPlatte.length).toBeGreaterThan(3);
const eingesammelt = files
.filter((datei) => FELD_PRAEFIXE.some((praefix) => datei.startsWith(praefix)))
.sort();
expect(eingesammelt).toEqual([...vonDerPlatte].sort());
});

it.each([...SCAFFOLDED_MODULES])(
"Modul %s besitzt echten Inhalt und eine oeffentliche API",
(slug) => {
Expand Down
63 changes: 63 additions & 0 deletions apps/api/test/architecture/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,47 @@ const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/];

const UI_PACKAGE = "packages/ui/src/";

/**
* Was die FELD-SHELL importieren darf (EYT-113).
*
* Die Mitarbeiter-Shell (`apps/web/app/feld/`, `apps/web/components/feld/`,
* `apps/web/lib/feld/`) teilt Auth-, API-, Vertrags- und Domainbasis mit der
* Werkbank — aber KEINE Werkbank-Oberflaeche: Admin- und Kosten-Komponenten
* duerfen das Mitarbeiter-Bundle nicht erreichen, auch nicht unbeabsichtigt.
* Wieder eine ALLOWLIST (dieselbe Lehre wie `domain-allowlist`): eine
* Sperrliste `components/kosten-*` liesse jede kuenftige Admin-Komponente
* durch, bis jemand die Liste pflegt.
*
* `@easytree/domain` steht bewusst NICHT hier: die Feld-Shell dieses
* Inkrements braucht es nicht, und eine Allowlist waechst erst, wenn ein
* realer Konsument es verlangt.
*/
const FELD_SHELL_SCOPES: readonly string[] = [
"apps/web/app/feld/",
"apps/web/components/feld/",
"apps/web/lib/feld/",
];

const FELD_ALLOWED_PACKAGES: readonly RegExp[] = [
/^react$/,
/^next$/,
/^next\/.+$/,
/^@easytree\/ui$/,
/^@easytree\/contracts$/,
];

/**
* Wohin relative Importe der Feld-Shell aufgeloest zeigen duerfen: in die
* Feld-Verzeichnisse selbst und in die geteilte Infrastruktur `lib/`
* (Session-Provider, Gateways, Proxyziel). `components/` ausserhalb von
* `components/feld/` ist Werkbank-Oberflaeche und bleibt draussen.
*/
const FELD_ALLOWED_RESOLVED: readonly string[] = [
"apps/web/app/feld/",
"apps/web/components/feld/",
"apps/web/lib/",
];

export const RULES: readonly Rule[] = [
{
// ADR-001 Z. 74 — als Allowlist formuliert, siehe Dateikopf.
Expand Down Expand Up @@ -356,6 +397,28 @@ export const RULES: readonly Rule[] = [
return null;
},
},
{
// EYT-113 — die Import-Grenze der Zwei-Client-Architektur, als Allowlist.
id: "feld-shell-boundary",
inScope: (file): boolean => FELD_SHELL_SCOPES.some((scope) => file.startsWith(scope)),
check: (ref): string | null => {
if (ref.isTypeReference) {
return `Die Feld-Shell darf keine /// <reference types="${ref.specifier}" /> fuehren — sie zieht eine ganze Ambient-Oberflaeche in den Scope.`;
}
if (!ref.specifier.startsWith(".")) {
if (FELD_ALLOWED_PACKAGES.some((allowed) => allowed.test(ref.specifier))) return null;
return `"${ref.specifier}" steht nicht auf der Importliste der Feld-Shell (EYT-113). Erlaubt sind react, next, @easytree/ui und @easytree/contracts. Der Ausweg ist NICHT, diese Liste blind zu erweitern: was die Feld-Shell braucht, kommt als geteiltes Primitive nach packages/ui oder als geteilte Infrastruktur nach apps/web/lib.`;
}
const resolved = ref.resolved;
if (resolved === null) {
return `Relativer Import "${ref.specifier}" ist nicht aufloesbar — der Scanner koennte ihn sonst stillschweigend uebergehen.`;
}
if (!FELD_ALLOWED_RESOLVED.some((prefix) => resolved.startsWith(prefix))) {
return `Die Feld-Shell darf nicht nach "${resolved}" greifen (EYT-113): Werkbank-Komponenten und -Seiten bleiben aus dem Mitarbeiter-Bundle draussen.`;
}
return null;
},
},
];

export function evaluate(refs: readonly ImportRef[]): {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from "next";

import { PageHeader } from "@easytree/ui";

import { LoginForm } from "../../components/login-form";
import { LoginForm } from "../../../components/login-form";

export const metadata: Metadata = { title: "Anmelden — easyTree" };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import Link from "next/link";
import { IdSchema } from "@easytree/contracts";
import { Card, PageHeader } from "@easytree/ui";

import { KostenAnsicht } from "../../components/kosten-ansicht";
import { KostenZugang } from "../../components/kosten-zugang";
import { KostenAnsicht } from "../../../components/kosten-ansicht";
import { KostenZugang } from "../../../components/kosten-zugang";

export const metadata: Metadata = { title: "Kosten — easyTree" };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Metadata } from "next";

import { PageHeader } from "@easytree/ui";

import { KostenZugang } from "../../../components/kosten-zugang";
import { RateManagement } from "../../../components/rate-management";
import { KostenZugang } from "../../../../components/kosten-zugang";
import { RateManagement } from "../../../../components/rate-management";

export const metadata: Metadata = { title: "Stundensätze — easyTree" };

Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/(werkbank)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";

import { AppShell } from "../../components/app-shell";

/**
* Werkbank-Layout (EYT-113): die bestehende Admin-/Werkbank-Shell gilt nur
* noch für die Routen dieser Route Group. Die Route Group ändert keine URLs,
* sie zieht die Shell-Grenze — die Feld-Shell unter `app/feld/` hat ein
* eigenes Layout und eine eigene Navigation.
*/
export default function WerkbankLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
}
26 changes: 26 additions & 0 deletions apps/web/app/(werkbank)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";

import { StartInhalt } from "../../components/start-inhalt";
import { leseServerSitzung } from "../../lib/feld/sitzung-server";
import { startShellFuer } from "../../lib/feld/start-shell";

/**
* Werkbank-Startseite mit Start-Shell-Dispatch (EYT-113): die zulaessige
* Start-Shell folgt SERVERSEITIG aus der real verifizierten Session. Wer
* ausschliesslich als member arbeitet, gehoert ins Feld und wird dorthin
* geleitet — die Route selbst verleiht kein Recht, sie liest nur.
*
* Abgemeldete und NICHT PRUEFBARE Sitzungen sehen die Startseite unveraendert:
* sie ist oeffentlich, und Nichtwissen darf niemanden aussperren oder
* umleiten (fail-open ist hier richtig, weil kein Inhalt geschuetzt ist —
* geschuetzte Inhalte pruefen API und RLS).
*/
export default async function HomePage() {
const kopfzeilen = await headers();
const sitzung = await leseServerSitzung(kopfzeilen.get("cookie"));
if (sitzung.zustand === "angemeldet" && startShellFuer(sitzung.session) === "feld") {
redirect("/feld");
}
return <StartInhalt />;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EUROPE_BERLIN } from "@easytree/domain";

import { PlanungAnsicht } from "../../components/planung-ansicht";
import { PlanungsWerkbank } from "../../components/planungs-werkbank";
import { wochenmodell, type Fehlergrund } from "../../lib/wochennavigation";
import { PlanungAnsicht } from "../../../components/planung-ansicht";
import { PlanungsWerkbank } from "../../../components/planungs-werkbank";
import { wochenmodell, type Fehlergrund } from "../../../lib/wochennavigation";

/**
* Einstieg in die Planungswerkbank (EYT-50, erweitert um EYT-140 M5).
Expand Down
44 changes: 44 additions & 0 deletions apps/web/app/feld/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";

import { ErrorState } from "@easytree/ui";

import { FeldShell } from "../../components/feld/feld-shell";
import { leseServerSitzung } from "../../lib/feld/sitzung-server";

export const metadata: Metadata = { title: "Feld — easyTree" };

/**
* Server-Gate der Feld-Shell (EYT-113): die zulaessige Shell folgt aus der
* REAL VERIFIZIERTEN Session — die API prueft die Cookies, nicht diese
* Datei, und nicht die Route. `headers()` macht den Teilbaum dynamisch;
* das Proxyziel wird damit nie zur Bauzeit gelesen (EYT-126).
*
* Fail-closed in beide Richtungen: Abgemeldete werden zur Anmeldung
* geleitet, und NICHTWISSEN (API nicht erreichbar, Antwort unlesbar) ist
* nicht abgemeldet — es zeigt eine ehrliche Fehlerflaeche statt Shell.
*/
export default async function FeldLayout({ children }: { children: ReactNode }) {
const kopfzeilen = await headers();
const sitzung = await leseServerSitzung(kopfzeilen.get("cookie"));

if (sitzung.zustand === "abgemeldet") {
redirect("/anmelden");
}

if (sitzung.zustand === "unbekannt") {
return (
<main id="hauptinhalt" tabIndex={-1} className="eyt-app-shell__main feld-gate">
<ErrorState
data-testid="feld-sitzung-unbekannt"
title="Anmeldung nicht prüfbar"
description="Die Anmeldung konnte serverseitig nicht geprüft werden. Bitte versuche es später erneut."
/>
</main>
);
}

return <FeldShell>{children}</FeldShell>;
}
19 changes: 19 additions & 0 deletions apps/web/app/feld/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PageHeader } from "@easytree/ui";

import { FeldStart } from "../../components/feld/feld-start";

/**
* Startseite der Feld-App (EYT-113). Muster wie die Werkbank-Seiten:
* Fragment, KEIN eigenes `<main>` — die Landmark stellt die Feld-Shell.
*/
export default function FeldPage() {
return (
<>
<PageHeader
title="Feld"
description="Dein Einstieg für den Einsatz — angemeldet mit deinem easyTree-Konto."
/>
<FeldStart />
</>
);
}
49 changes: 49 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,52 @@ body {
color: var(--eyt-text-secondary);
font-size: 0.875rem;
}

/* ============================================================
* Basisdesign v2.0 — Feld-Shell (EYT-113)
* ============================================================ */

/* Die Feld-Shell teilt das Geruest (eyt-app-shell__*) mit der Werkbank;
eigen ist nur die Feld-Informationsarchitektur. Mobile-first: alles
einspaltig, keine Mindestbreiten — 320 px ist die Grundflaeche. */

.feld-marke {
color: var(--eyt-text-secondary);
font-weight: 400;
}

.feld-organisationen {
margin: 0 0 var(--space);
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}

.feld-organisation {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: var(--space);
border: 1px solid var(--eyt-border-default);
border-radius: var(--radius);
background: var(--eyt-bg-surface);
}

.feld-organisation__name {
font-weight: 700;
}

.feld-organisation__rolle {
color: var(--eyt-text-secondary);
font-size: 0.875rem;
}

/* Ehrliche Fehlerflaeche des Server-Gates: dieselbe main-Vermessung wie die
Shell, damit die Meldung nicht am Viewportrand klebt. */
.feld-gate {
display: grid;
align-content: start;
}
Loading
Loading