diff --git a/.agents/skills/content-authoring/SKILL.md b/.agents/skills/content-authoring/SKILL.md index 55bdce1..009e1a6 100644 --- a/.agents/skills/content-authoring/SKILL.md +++ b/.agents/skills/content-authoring/SKILL.md @@ -429,6 +429,70 @@ If the collection feeds public pages or admin block previews, also verify that t --- +## Collection Validators + +Validatoren definieren Sicherheitsregeln und Typ-Constraints, indem sie als `validator`-Key innerhalb der `fields`-Definitionen einer Collection-YAML (`api/collections/*.yml`) konfiguriert werden. + +**Unterschied Client- vs. Serverseitige Validatoren:** + +- **Serverseite (`tibi-server`)**: Validatoren werden zentral im Go-Backend bei jedem Datensatz-Schreibvorgang (`POST` / `PUT`) ausgeführt (nach den `validate`-Hooks). Wenn Daten nicht den Constraints entsprechen, erfolgt ein Abbruch (`400 Bad Request`). +- **Clientseite (`tibi-admin-nova`)**: Das CMS-Admin-Interface liest diese Validator-Regeln automatisch über das OpenAPI-Schema ein und wendet sie instant als Client-Side-Validierung in den Formularen an (Rote Markierungen und Check vor dem eigentlichen API-Call). **Validatoren müssen daher nur 1x zentral in der YAML definiert werden.** + +**Häufige Validator-Optionen je Feldtyp:** + +- **Generell**: + - `required: true` (Zwingendes Pflichtfeld) + - `allowZero: true` (Erlaubt die explizite Eingabe von `""` oder `0`, selbst wenn `required: true` aktiv ist) + - `in: ["wert1", "wert2"]` (Nur dieser exakte Pool an primitiven Werten ist erlaubt) + - `eval: "$this.length >= 3 && $this.length <= 100"` (Serverseitige Javascript-Evaluation für Custom-Logik) +- **Einfache Texte (`string`)**: + - `minLength: X` und `maxLength: Y` + - `pattern: "^[a-zA-Z0-9]+$"` (Prüft Regex-Match des kompletten Werts) + - `format: email` (oder `url`, `uuid`, `slug` für eingebaute Regex-Prüfungen) +- **Zahlen (`number`, `float`)**: + - `min: X` und `max: Y` +- **Datum/Zeit (`date`, `datetime`, `time`)**: + - `minDate: "YYYY-MM-DD"` und `maxDate: "YYYY-MM-DD"` (Zulässige Zeitgrenzen) +- **Listen/Arrays (`string[]`, `object[]`)**: + - `minItems: X` und `maxItems: Y` +- **Dateien/Bilder (`file`, `file[]`)**: + - `maxFileSize: "50MB"` (und `minFileSize`) + - `accept: ["image/png", "image/webp"]` (Erlaubte MIME-Types) + - Constraints für Bildabmessungen konfigurierbar via Sub-Objekt: + ```yaml + image: + minWidth: 800 + maxWidth: 2400 + minHeight: 600 + maxHeight: 1800 + ``` + +**Beispiel für die Einbindung in einer Collection:** + +```yaml +fields: + - name: internalName + type: string + validator: + required: true + maxLength: 100 + meta: + label: { de: "Interner Name", en: "Internal Name" } + + - name: externalLink + type: string + validator: + format: url + meta: + label: Externe URL + + - name: document + type: file + validator: + maxFileSize: "20MB" + accept: ["application/pdf"] +``` + ## Seed data pattern (Playwright) Test seed data uses `_testdata: true` as a hidden marker field. **Real content must NEVER use this flag** — otherwise test teardown will delete it. diff --git a/.agents/skills/tibi-hook-authoring/SKILL.md b/.agents/skills/tibi-hook-authoring/SKILL.md index fc28ddf..725d539 100644 --- a/.agents/skills/tibi-hook-authoring/SKILL.md +++ b/.agents/skills/tibi-hook-authoring/SKILL.md @@ -141,6 +141,52 @@ For `GET /:collection/:id`, the Go server sets `_id` automatically from the URL GET read hooks should **not** set their own `_id` filter for `req.param("id")`. Only add authorization filters (e.g. `{ userId: userId }`). + +## Interne DB-Lookups in Hooks (Read & Write) + +Innerhalb von goja-Hooks hast du über die `context.db`-API Vollzugriff auf die lokale MongoDB. Dies ist essenziell für komplexe Prüfungen (z. B. "Gehört der angemeldete User wirklich zur ID im Foreign-Key des Objekts?"). + +**Wichtige Konzepte für DB-Calls in Hooks:** +1. **Keine Automatik-Lookups (`_lookup`) in Hook-Queries:** Der Go-Befehl `context.db.find` liefert nur die flachen Datenbank-Dokumente als Array. Die in der REST-API verfügbare `lookup`-Automatik für Foreign-Keys wird in den internen Backend-Hooks *nicht* angewendet. Du musst die verknüpften Collections ggf. manuell nachladen. +2. **Immer Arrays:** `context.db.find` gibt **immer** ein Array zurück, auch wenn du `limit: 1` setzt. +3. **Rechte ignorierend:** Die `context.db.*`-Methoden umgehen alle `permissions` der YAML-Rollen. Du lädst als System-Benutzer! + +**Beispiel: Datensatz validieren / verknüpftes Element prüfen** + +```javascript +// hooks/my_action/post.before +(function() { + var userId = context.auth().id; + var submittedRefId = context.data.refId; + + // 1. Manuell nachladen + var targetList = context.db.find("target_collection", { + filter: { id: submittedRefId }, + limit: 1 // Begrenzen für Performance + }); + + if (targetList.length === 0) { + throw { status: 404, json: { error: "Ziel nicht gefunden" } }; + } + + var target = targetList[0]; + + // 2. Custom Security Check + if (target.ownerId !== userId) { + throw { status: 403, json: { error: "Keine Berechtigung für dieses Ziel" } }; + } + + // ... Hook fortsetzen +})(); +``` + +**Verfügbare DB-Methoden in `context.db`:** +* `context.db.find(collection, { filter: {}, selector: {}, sort: [], limit: 10 })` +* `context.db.count(collection, { filter: {} })` +* `context.db.create(collection, { field: "value" })` +* `context.db.update(collection, "id_string", { field: "new_value" })` (bzw. mit Mongo-Operatoren `$set`, `$inc`, etc.) +* `context.db.delete(collection, "id_string")` + ## Current hook surfaces that matter for website projects - Collection CRUD hooks under `get`, `post`, `put`, `delete` diff --git a/api/collections/content.yml b/api/collections/content.yml index 60ad17f..d75b372 100644 --- a/api/collections/content.yml +++ b/api/collections/content.yml @@ -12,7 +12,6 @@ meta: label: name secondary: path tertiary: lang - badge: type image: _pagebuilderThumbnail pagebuilder: screenshot: @@ -25,11 +24,15 @@ meta: sidebar: - group: publishing label: { de: "Veröffentlichung", en: "Publishing" } - - group: settings - label: { de: "Einstellungen", en: "Settings" } - group: seo label: { de: "SEO", en: "SEO" } +hooks: + get: + read: + type: javascript + file: hooks/filter_public.js + permissions: public: methods: @@ -53,11 +56,30 @@ fields: meta: label: { de: "Aktiv", en: "Active" } position: sidebar:publishing - - name: type - type: string + - name: publication + type: object meta: - label: { de: "Typ", en: "Type" } - position: sidebar:settings + label: { de: "Veröffentlichungszeitraum", en: "Publication window" } + position: sidebar:publishing + drillDown: false + widget: containerLessObject + subFields: + - name: from + type: date + meta: + label: { de: "Von", en: "From" } + widget: datetime + containerProps: + layout: + size: col-6 + - name: to + type: date + meta: + label: { de: "Bis", en: "Until" } + widget: datetime + containerProps: + layout: + size: col-6 - name: lang type: string meta: @@ -70,23 +92,40 @@ fields: position: sidebar:settings - name: name type: string + validator: + required: true meta: label: { de: "Name", en: "Name" } + helperText: + de: "Eindeutiger Name für diese Seite, z.B. 'Startseite'." + en: "Unique name for this page, e.g. 'Homepage'." + containerProps: + layout: + size: col-6 - name: path type: string + validator: + pattern: ^\/[a-z0-9\-\/]*$ + required: true meta: label: { de: "Pfad", en: "Path" } + helperText: + de: "URL-Pfad für diese Seite, z.B. '/ueber-uns'." + en: "URL path for this page, e.g. '/about-us'." + containerProps: + layout: + size: col-6 - name: alternativePaths type: object[] meta: label: { de: "Alternative Pfade", en: "Alternative Paths" } + position: sidebar:seo + widget: containerLessObjectArray subFields: - name: path type: string - - name: teaserText - type: string - meta: - label: { de: "Teasertext", en: "Teaser Text" } + validator: + pattern: ^\/[a-z0-9\-\/]*$ - name: _pagebuilderThumbnail type: file meta: diff --git a/api/collections/medialib.yml b/api/collections/medialib.yml index 410cea4..497621a 100644 --- a/api/collections/medialib.yml +++ b/api/collections/medialib.yml @@ -89,6 +89,9 @@ imageFilter: fields: - name: file type: file + validator: + required: true + maxFileSize: "50MB" meta: label: { de: "Datei", en: "File" } widget: file @@ -97,6 +100,8 @@ fields: maxHeight: 2048 - name: title type: string + validator: + maxLength: 255 meta: label: { de: "Titel", en: "Title" } - name: alt @@ -106,14 +111,20 @@ fields: subFields: - name: de type: string + validator: + maxLength: 500 meta: label: Deutsch - name: en type: string + validator: + maxLength: 500 meta: label: English - name: description type: string + validator: + maxLength: 2000 meta: label: { de: "Beschreibung", en: "Description" } widget: text diff --git a/api/collections/navigation.yml b/api/collections/navigation.yml index cab99ab..e541790 100644 --- a/api/collections/navigation.yml +++ b/api/collections/navigation.yml @@ -60,6 +60,9 @@ permissions: fields: - name: language type: string + validator: + in: ["de", "en"] + required: true meta: label: { de: "Sprache", en: "Language" } position: sidebar:settings @@ -71,6 +74,9 @@ fields: name: { de: "Englisch", en: "English" } - name: type type: string + validator: + in: ["header", "footer"] + required: true meta: label: { de: "Typ", en: "Type" } helperText: { de: "header oder footer", en: "header or footer" } @@ -89,6 +95,9 @@ fields: subFields: - name: name type: string + validator: + required: true + maxLength: 100 meta: label: { de: "Bezeichnung", en: "Label" } - name: page @@ -105,10 +114,14 @@ fields: label: { de: "Externer Link", en: "External Link" } - name: externalUrl type: string + validator: + maxLength: 1024 meta: label: { de: "Externe URL", en: "External URL" } - name: hash type: string + validator: + pattern: ^[a-zA-Z0-9_-]+$ meta: label: { de: "Anker", en: "Anchor" } - name: elements diff --git a/tests/api/helpers/seed-data.ts b/tests/api/helpers/seed-data.ts index be9c1b6..3bacc85 100644 --- a/tests/api/helpers/seed-data.ts +++ b/tests/api/helpers/seed-data.ts @@ -64,12 +64,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: true, - type: "page", lang: "de", translationKey: SEEDED_TEST_CONTENT.home.translationKey, name: "Playwright Startseite", path: SEEDED_TEST_CONTENT.home.path, - teaserText: "Deterministisch erzeugte Testseite fuer API- und E2E-Tests.", meta: { title: "Playwright Startseite", description: "Seeded Startseite fuer stabile Playwright-Tests.", @@ -145,12 +143,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: true, - type: "page", lang: "en", translationKey: SEEDED_TEST_CONTENT.home.translationKey, name: "Playwright Home", path: SEEDED_TEST_CONTENT.home.path, - teaserText: "Deterministically seeded page for API and E2E coverage.", meta: { title: "Playwright Home", description: "Seeded home page for stable Playwright tests.", @@ -226,12 +222,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: true, - type: "page", lang: "de", translationKey: SEEDED_TEST_CONTENT.pagebuilderPreview.translationKey, name: "Playwright Pagebuilder Preview", path: SEEDED_TEST_CONTENT.pagebuilderPreview.path, - teaserText: "Seeded Admin-Vorschauseite fuer Pagebuilder-Registry und Bildrendering.", meta: { title: "Playwright Pagebuilder Preview", description: "Seeded Seite fuer Pagebuilder- und Medialib-Vorschau-Tests.", @@ -264,12 +258,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: true, - type: "page", lang: "de", translationKey: SEEDED_TEST_CONTENT.contact.translationKey, name: "Playwright Kontakt", path: SEEDED_TEST_CONTENT.contact.path, - teaserText: "Seeded Kontaktseite fuer Formular- und Routing-Tests.", meta: { title: "Playwright Kontakt", description: "Seeded Kontaktseite fuer Playwright.", @@ -294,12 +286,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: true, - type: "page", lang: "en", translationKey: SEEDED_TEST_CONTENT.contact.translationKey, name: "Playwright Contact", path: SEEDED_TEST_CONTENT.contact.path, - teaserText: "Seeded contact page for form and routing tests.", meta: { title: "Playwright Contact", description: "Seeded contact page for Playwright.", @@ -324,12 +314,10 @@ function getSeededContentEntries(previewImageId: string) { { _testdata: true, active: false, - type: "page", lang: "de", translationKey: SEEDED_TEST_CONTENT.inactive.translationKey, name: "Playwright Inaktiv", path: SEEDED_TEST_CONTENT.inactive.path, - teaserText: "Nicht aktive Seed-Seite fuer 404-Checks.", meta: { title: "Playwright Inaktiv", description: "Nicht aktive Seed-Seite fuer Routing-Tests.", diff --git a/tests/e2e-admin/content-config.spec.ts b/tests/e2e-admin/content-config.spec.ts index faa92f6..8eb988a 100644 --- a/tests/e2e-admin/content-config.spec.ts +++ b/tests/e2e-admin/content-config.spec.ts @@ -23,7 +23,6 @@ test.describe("Admin content collection config", () => { await expect(page.getByLabel("Name")).toBeVisible() await expect(page.getByLabel("Pfad")).toBeVisible() - await expect(page.getByLabel("Teasertext")).toBeVisible() await expect(page.getByText("Alternative Pfade")).toBeVisible() await expect(page.getByText("Inhaltsblöcke").first()).toBeVisible() await expect(page.getByRole("button", { name: /Desktop \(1280px\)/ })).toBeVisible() diff --git a/types/global.d.ts b/types/global.d.ts index 05778c7..88c4ee1 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -124,13 +124,11 @@ interface ContentEntry { from?: string | Date to?: string | Date } - type?: string lang?: string translationKey?: string name?: string path?: string alternativePaths?: { path?: string }[] - teaserText?: string blocks?: ContentBlockEntry[] meta?: { title?: string