From c55e6f2dfef5f2738db8605bde52ef9e10406a0a Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:21:21 +0000 Subject: [PATCH] Plan: Funktionen-Tab und News-Pinnwand Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-30-funktionen-news.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-funktionen-news.md diff --git a/docs/superpowers/plans/2026-09-30-funktionen-news.md b/docs/superpowers/plans/2026-09-30-funktionen-news.md new file mode 100644 index 0000000..63e7bd7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-funktionen-news.md @@ -0,0 +1,322 @@ +# Funktionen-Tab und News-Pinnwand Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Kunden buchen im Portal-Tab „Funktionen“ die News-Pinnwand (30 € einmalig), wir bauen sie per Ticket ein, danach schreiben sie Beiträge, die ein Skript auf ihrer Website anzeigt; „Fortschritt“ wandert unter „Projekte“. + +**Architecture:** Zustand je (Kunde, Projekt) im Collection `portalFeatures`; Zahlung per Stripe Checkout `mode: 'payment'`, ausgewertet im bestehenden Webhook in `routes/abo.js`; Freischaltung durch einen 5-Minuten-Job, der geschlossene WOMS-Tickets erkennt. Beiträge in `newsPosts`, Fotos mit `sharp` bereinigt unter `upload-data/news/`. Öffentliche Lese-API `/api/public/news/:siteKey` + statisches Einbau-Skript `public/embed/news.js`. Neue Frontend-Teile in eigenen Dateien (`public/funktionen.js/.css`), `app.js` bekommt nur Router- und Nav-Anpassungen. + +**Tech Stack:** Node ≥ 18 (getestet auf 20.19.5 und 24), Express 4, node-appwrite, stripe 19, nodemailer + `server/mail-templates`, neu: `sharp`. Tests: `node --test` mit `test/support/` (appwriteMock, fakeStripe, mailSink, testApp, fixtures), Browser-Checks mit Playwright (playwright-core aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`). + +**Spec:** `docs/superpowers/specs/2026-09-30-funktionen-news-design.md` (Entscheidungen E1–E9) + +## Global Constraints + +- Einrichtung **30,00 € brutto einmalig** (`amountCents: 3000`, `tax_behavior: 'inclusive'`), Nutzung kostenlos, kein Abo, kein Testmonat. +- Feature-Zustände exakt: `off` | `ordered` | `setup` | `active` | `disabled`; `enabled === (state === 'active')`. +- Anzeigedauer-Schlüssel exakt: `1w` | `2w` | `1m` (Vorauswahl) | `3m` | `open` | `date`; `date` frühestens morgen, höchstens 1 Jahr, endet 23:59:59.999 Europe/Berlin. +- Website zeigt **6** Beiträge pro Seite, neueste zuerst. +- Titel ≤ 60 Zeichen, Text ≤ 400 Zeichen, Foto ≤ 10 MB (JPEG, PNG, WebP, HEIC), Ausgabe WebP längste Kante ≤ 1600 px + Vorschau ≤ 480 px, **alle Metadaten entfernt**. +- Zettelfarben exakt: `sun` | `sky` | `coral` | `white`; Arten exakt: `note` | `photo` | `combo`. +- Rate-Limits: 30 Schreibvorgänge + 20 Uploads pro Kunde und Stunde; öffentliche API 120 Anfragen/min pro IP. +- Öffentliche Antworten enthalten **nie** `customerId`, `projectId`, Mail, Ticket- oder Stripe-IDs. +- `siteKey`: 16 Zeichen base32 (a–z, 2–7) aus `crypto.randomBytes`. +- Kundentexte: Du-Form wie das übrige Portal; im Einbau-Skript nie `innerHTML` mit Kundentext. +- Keine `alert/confirm/prompt`; Rückfragen in der Seite. +- Tab-Reihenfolge: Projekte · Funktionen · Rechnungen · Chat · Verträge. Hashes: `#funktionen`, `#news`; `#fortschritt` → Projekte + Scroll zur Sektion `#pj-activity`. +- Commits mit `Co-Authored-By: Claude Opus 5.5 `; nichts pushen ohne OK des Nutzers. + +## Review Focus + +1. **SEPA zahlt verzögert:** `checkout.session.completed` mit `payment_status: 'unpaid'` darf nicht freischalten und kein Ticket anlegen; erst `checkout.session.async_payment_succeeded` → Task 4 Test `sepa_wartet_auf_zahlung`. +2. **Doppelte Webhooks / doppelte Buchung:** zweiter Webhook legt kein zweites Ticket an; zweiter Checkout-Versuch bei `setup`/`active` → 409 → Task 4 Tests `webhook_idempotent`, `checkout_bei_setup_409`. +3. **Beitrag genau an der Ablaufgrenze und über Mitternacht in Berlin:** `isHanging` an `visibleUntil - 1 ms` true, an `visibleUntil` false; `date` = Ende des Berliner Tages auch in der Sommerzeit-Umstellungswoche → Task 6 Test `ablauf_grenzen_berlin`. +4. **Handy-Foto mit GPS und Hochkant-EXIF:** Ausgabe ohne EXIF/GPS, richtig gedreht → Task 5 Test `exif_gps_entfernt_und_gedreht`. +5. **Kundentext mit HTML (``) auf der Website:** wird als Text angezeigt → Task 10 Check `xss_als_text`. + +--- + +## Task 1: Schema `portalFeatures` + `newsPosts` + +**Files:** +- Modify: `scripts/setup-abo-collections.mjs` (vor `console.log('Fertig.')`) +- Modify: `server/config.js` (Collection-ID `newsPosts`) +- Test: `test/support.test.js` (Schema-Schlüssel vorhanden) + +**Interfaces:** +- Produces: `config.collections.newsPosts` (env `APPWRITE_COLLECTION_NEWS_POSTS`, Default `'newsPosts'`); `schemaKeys('portalFeatures')`, `schemaKeys('newsPosts')` für alle späteren Tests. + +- [ ] **Step 1: Failing test** `schema_funktionen_news` in `test/support.test.js`: `schemaKeys('portalFeatures')` enthält `customerId, projectId, featureKey, enabled, unlockedByPurchase, purchaseStatus, state, siteKey, ticketId, stripeSessionId, stripeInvoiceId, invoiceUrl, orderedAt, activatedAt, disabledAt, consentText, consentAt, consentIp, consentUserAgent, adminNote`; `schemaKeys('newsPosts')` enthält `customerId, projectId, type, title, text, imageId, paper, duration, visibleFrom, visibleUntil, takenDownAt, createdAt, updatedAt`. +- [ ] **Step 2:** `npm test -- --test-name-pattern=schema_funktionen_news` → FAIL „Collection portalFeatures fehlt im Setup-Skript“. +- [ ] **Step 3:** Zwei `ensureCollection`-Blöcke ergänzen. `portalFeatures` (Name „Portal-Funktionen“; ensureCollection überspringt Bestehendes per 409): Strings 64 für IDs/`featureKey`/`purchaseStatus`/`state`(16)/`siteKey`(16), `invoiceUrl` 500, `consentText` 1000, `consentIp` 64, `consentUserAgent` 300, `adminNote` 300; boolean `enabled`, `unlockedByPurchase`; datetime `orderedAt, activatedAt, disabledAt, consentAt`. Indexe: `idx_customer` (customerId), `idx_state` (state), `idx_sitekey` unique (siteKey). `newsPosts` (Name „News-Beiträge“): IDs 64, `type` 8, `title` 60, `text` 400, `imageId` 64, `paper` 8, `duration` 8, datetime `visibleFrom, visibleUntil, takenDownAt, createdAt, updatedAt`. Index `idx_project_from` (projectId, visibleFrom). Kopfkommentar des Skripts um beide Collections ergänzen. +- [ ] **Step 4:** Test → PASS; `npm test` komplett grün. +- [ ] **Step 5:** Commit „Schema: portalFeatures-Zustände und newsPosts“. + +## Task 2: Einmal-Preis „News-Einrichtung“ + +**Files:** +- Modify: `server/config.js` (neben `mailStorage`), `server/services/stripeBilling.js:54-106` +- Test: `test/stripeBilling.test.js` + +**Interfaces:** +- Produces: `config.newsSetup = { key: 'newsSetup', name: 'News-Pinnwand – Einrichtung', amountCents: 3000, lookupKey: 'webklar_news_einrichtung_3000', oneTime: true, priceNickname: 'News-Einrichtung 30 €' }`; `ensurePrice('newsSetup') → Promise`. + +- [ ] **Step 1: Failing test** `news_einrichtung_einmalpreis`: gegen fakeStripe legt `ensurePrice('newsSetup')` einen Preis **ohne** `recurring` mit `unit_amount 3000`, `lookup_key 'webklar_news_einrichtung_3000'`, Produkt-Metadaten `webklar: 'news-einrichtung'` an; zweiter Aufruf erzeugt keinen zweiten Preis. +- [ ] **Step 2:** Test → FAIL. +- [ ] **Step 3:** `PRODUCT_METADATA.newsSetup = 'news-einrichtung'`; `catalogFor('newsSetup')` → `config.newsSetup`; in `ensurePrice` `recurring` nur setzen, wenn `!catalog.oneTime`. `scripts/stripe-products.mjs` listet den neuen Preis mit. +- [ ] **Step 4:** Test → PASS, bestehende stripeBilling-Tests grün. +- [ ] **Step 5:** Commit „Stripe: Einmalpreis News-Einrichtung 30 €“. + +## Task 3: Feature-Zustand und Ticket (`siteFeatures`) + +**Files:** +- Create: `server/services/siteFeatures.js` +- Modify: `server/services/ticketAdmin.js` (neue Funktion), `server/routes/features.js` (liest `state`) +- Test: `test/siteFeatures.test.js` + +**Interfaces:** +- Consumes: `config.collections.portalFeatures`, `websiteProjects`, `workorders`, `customers`; `listDocuments/getDocument/createDocument/updateDocument` aus `appwriteAdmin.js`. +- Produces: + - `FEATURE_KEYS = ['news']`, `STATES = ['off','ordered','setup','active','disabled']` + - `listCustomerFeatures(customerId) → Promise>` mit `FeatureView = { state, siteKey|null, ticketWoid|null, orderedAt|null, activatedAt|null, invoiceUrl|null }` (ein Eintrag je Website-Projekt des Kunden, fehlendes Dokument ⇒ `state: 'off'`) + - `getFeatureDoc(customerId, projectId, key) → Promise` + - `assertOwnProject(customerId, projectId) → Promise` (wirft `{status:403}`) + - `markOrdered({ customerId, projectId, key, sessionId, consent }) → Promise` + - `markPaid({ sessionId, invoiceId, invoiceUrl }) → Promise<{ doc, created: boolean }>` (idempotent: bereits `setup`/`active` ⇒ `created:false`) + - `markCheckoutFailed(sessionId) → Promise` (⇒ `off`) + - `activate(docId, { by }) → Promise`, `disable(docId, { by }) → Promise`, `adminActivate(customerId, projectId, key, { by }) → Promise` + - `activateClosedTickets(now?) → Promise>` (alle `setup` mit Ticket-Status `'Closed'` ⇒ `active`) + - `newSiteKey() → string` (16 Zeichen base32) + - `findActiveBySiteKey(siteKey) → Promise` + - in `ticketAdmin.js`: `createFeatureTicket({ customerId, project, featureKey, siteKey }) → Promise<{ ticketId, woid }>` (Felder wie `ensureProjectTicket`: `topic` `"News-Pinnwand einbauen – "`, `type 'Webpage'`, `status 'Open'`, `priority 1`, `serviceType 'Remote'`, `systemType 'n/a'`, `details` mit Projekt, Domain, siteKey und Einbau-Hinweis „
\"> + https://project.webklar.com/embed/news.js“) + +- [ ] **Step 1: Failing tests** in `test/siteFeatures.test.js` (appwriteMock mit `schemaKeys` für portalFeatures, websiteProjects, workorders, customers): + - `ohne_dokument_ist_off`: Kunde mit 2 Projekten ⇒ 2 Einträge, beide `state 'off'`. + - `bezahlt_legt_ticket_an`: `markOrdered` → `markPaid` ⇒ `state 'setup'`, `ticketId` gesetzt, genau 1 workorder mit `topic` beginnend „News-Pinnwand einbauen“. + - `webhook_idempotent`: zweites `markPaid` mit gleicher Session ⇒ `created:false`, weiterhin 1 workorder. + - `ticket_geschlossen_aktiviert`: workorder `status 'Closed'` ⇒ `activateClosedTickets()` liefert 1 Doc, `state 'active'`, `enabled true`, `activatedAt` gesetzt; Ticket `'Open'` ⇒ bleibt `setup`. + - `fremdes_projekt_403`: `assertOwnProject('c1', projektVon_c2)` wirft status 403. + - `sitekey_format`: `newSiteKey()` passt auf `/^[a-z2-7]{16}$/`, 100 Aufrufe ohne Dublette. + - `admin_aktiviert_ohne_zahlung`: `adminActivate` ⇒ `active`, `unlockedByPurchase false`, kein Ticket. +- [ ] **Step 2:** `node --test test/siteFeatures.test.js` → FAIL (Modul fehlt). +- [ ] **Step 3:** Implementieren. Eindeutigkeit (Kunde, Projekt, key) über feste Dokument-ID `f__` (≤ 36 Zeichen: bei längerer projectId sha1-Kürzung wie `eventIdFor`). `siteKey` wird beim ersten Anlegen vergeben und nie geändert. `routes/features.js` liefert zusätzlich `state`. +- [ ] **Step 4:** Tests → PASS; `npm test` grün. +- [ ] **Step 5:** Commit „Funktionen: Zustände, Ticket beim Kauf, Freischaltung bei geschlossenem Ticket“. + +## Task 4: Buchen, Checkout, Webhook, Mails, Job + +**Files:** +- Create: `server/routes/funktionen.js`, `server/mail-templates/K15 Bestellung Funktion.html`, `K16 Funktion bereit.html`, `T16 Funktion bestellt.html` +- Modify: `server/routes/abo.js` (`handleStripeEvent`), `server/index.js` (Router `/api/funktionen`, Job), `server/services/stripeBilling.js` (`createFeatureCheckout`) +- Test: `test/funktionenRoutes.test.js` + +**Interfaces:** +- Consumes: Task 2 `ensurePrice('newsSetup')`, Task 3 (alle Funktionen). +- Produces: + - `GET /api/funktionen` ⇒ `{ projects: listCustomerFeatures(...), setupEstimate: config.funktionen.setupEstimate }` (`config.funktionen.setupEstimate = 'meist innerhalb von 1–2 Wochen'`, `config.funktionen.consentText` = Wortlaut der Spec B1) + - `POST /api/funktionen/news/checkout` body `{ projectId, consent: true }` ⇒ `200 { url }` | `400` ohne consent | `403` fremdes Projekt | `409` bei `ordered` (nicht abgelaufen)/`setup`/`active` + - `createFeatureCheckout(customer, { projectId, key, consentAt }) → Promise<{ id, url }>`: `mode 'payment'`, `line_items [{ price, quantity 1 }]`, `invoice_creation.enabled true`, `payment_method_types` wie Abo-Checkout, `client_reference_id customerId`, `metadata { customerId, projectId, featureKey: 'news' }`, `expires_at` +24 h, `success_url …/dashboard.html#news?bestellt=1`, `cancel_url …/dashboard.html#news`. + - Webhook-Zweige (nur wenn `obj.mode === 'payment' && obj.metadata?.featureKey`): `checkout.session.completed` mit `payment_status 'paid'` und `checkout.session.async_payment_succeeded` ⇒ `markPaid` + bei `created` K15 an Kunden, T16 ans Team; `checkout.session.async_payment_failed` / `checkout.session.expired` ⇒ `markCheckoutFailed`; bei `async_payment_failed` zusätzlich Team-Mail T16 mit Block `zahlung_fehlgeschlagen`. Der Abo-Zweig bleibt unverändert (Bedingung `obj.mode !== 'subscription'` steht schon da). + - Job in `server/index.js`: `setInterval(runActivation, 5 * 60 * 1000).unref()`; `runActivation` = `activateClosedTickets()` + K16 je aktiviertem Doc. Zusätzlich ruft `GET /api/funktionen` vor der Antwort `activateClosedTickets()` für diesen Kunden (Filter customerId). + - Mail-Vorlagen im Format der vorhandenen (Kopfkommentar Betreff/Platzhalter). K15 Betreff „Deine Bestellung: News-Pinnwand“, Platzhalter `kunde_name, projekt, preis, zeit, rechnung_url`; K16 Betreff „Deine News-Pinnwand ist bereit“, `kunde_name, projekt, portal_url`; T16 Betreff „Neue Funktion bestellt: News-Pinnwand“, `kunde_name, email, projekt, woid, sitekey`. + +- [ ] **Step 1: Failing tests** in `test/funktionenRoutes.test.js` (startTestApp mit `/api/funktionen` und `/api/abo`, fakeStripe, mailSink): + - `liste_zustaende`: GET mit customerCookie ⇒ 200, Projekte mit `features.news.state 'off'`, `setupEstimate` gesetzt. + - `checkout_ohne_zustimmung_400`, `checkout_fremdes_projekt_403`. + - `checkout_legt_session_an`: 200 `{ url }`; fakeStripe hat Session mit `mode 'payment'`, Betrag 3000, Metadaten `featureKey 'news'`; Doc `state 'ordered'`, `consentAt` + `consentText` gespeichert. + - `checkout_bei_setup_409`. + - `sepa_wartet_auf_zahlung`: Webhook `checkout.session.completed` mit `payment_status 'unpaid'` ⇒ weiterhin `ordered`, 0 workorders, 0 Mails; danach `async_payment_succeeded` ⇒ `setup`, 1 workorder, Mails K15 + T16. + - `webhook_idempotent`: `completed` (paid) zweimal ⇒ 1 workorder, je 1 Mail. + - `abgelaufen_zurueck_auf_off`: `checkout.session.expired` ⇒ `off`, neuer Checkout wieder möglich. + - `abo_webhook_unveraendert`: bestehender Abo-Checkout-Test läuft weiter (`npm test`). + - `aktivierung_mailt`: workorder auf `Closed`, `runActivation()` ⇒ `active` + K16 an Kunden. +- [ ] **Step 2:** Tests → FAIL. +- [ ] **Step 3:** Implementieren; `runActivation` aus `siteFeatures.js` exportieren, damit der Test ihn direkt aufruft. +- [ ] **Step 4:** `npm test` grün; `node --test test/mailTemplates.test.js` erkennt die drei neuen Vorlagen ohne `problems`. +- [ ] **Step 5:** Commit „Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job“. + +## Task 5: Fotos (`newsImages`) + +**Files:** +- Create: `server/services/newsImages.js`, `test/fixtures/news/gps-hochkant.jpg` (kleines JPEG mit GPS-EXIF und Orientation 6, per Skript im Test-Setup erzeugt statt eingecheckt, falls sharp das schreiben kann) +- Modify: `package.json` (`sharp` ^0.34), `Dockerfile`/`deploy.sh` nur falls `npm ci` dort Plattform-Binaries braucht (prüfen, dass `deploy.sh` `npm install` im Container ausführt) +- Test: `test/newsImages.test.js` + +**Interfaces:** +- Produces: + - `NEWS_IMAGE_MAX_BYTES = 10 * 1024 * 1024` + - `storeNewsImage({ customerId, projectId, buffer }) → Promise<{ imageId, w, h }>` (schreibt `upload-data/news//.webp`, `-s.webp`, Sidecar `.json` `{ customerId, projectId, createdAt, claimed:false, w, h }`; wirft `{status:413}` bei Größe, `{status:415}` bei Typ) + - `claimNewsImage(projectId, imageId) → Promise`, `deleteNewsImage(projectId, imageId) → Promise` + - `newsImagePath(projectId, imageId, size: 'l'|'s') → string|null` (nur `[a-z0-9-]`-IDs, sonst null) + - `purgeUnclaimedNewsImages(now?) → Promise` (älter als 24 h und `claimed:false`) + - `newsImageRateLimited(customerId) → boolean` (20/h) + +- [ ] **Step 1: Failing tests:** + - `exif_gps_entfernt_und_gedreht`: Eingabe 1200×800 mit Orientation 6 + GPS ⇒ Ausgabe `sharp(...).metadata()` hat keine `exif`, Breite < Höhe. + - `verkleinert_auf_1600_und_480`: 4000×3000 ⇒ groß 1600×1200, klein 480×360, beide `format 'webp'`. + - `zu_gross_413`: 10 MB + 1 Byte ⇒ status 413. `falscher_typ_415`: PDF-Bytes ⇒ 415. + - `pfad_nur_saubere_ids`: `newsImagePath('p1', '../x', 'l') === null`. + - `unbenutzte_nach_24h_weg`: nicht beanspruchtes Bild mit `createdAt` −25 h ⇒ `purgeUnclaimedNewsImages()` === 1, Dateien weg; beanspruchtes bleibt. +- [ ] **Step 2:** Tests → FAIL. +- [ ] **Step 3:** Implementieren mit `sharp(buffer, { failOn: 'error' }).rotate().resize({ width:1600, height:1600, fit:'inside', withoutEnlargement:true }).webp({ quality:80 })` (ohne `.withMetadata()`); Typ-Erkennung über `sharp().metadata().format` ∈ `jpeg|png|webp|heif`. Upload-Wurzel wie `chatAttachments.js` aus `config`. +- [ ] **Step 4:** Tests → PASS auf Node 20 und 24. +- [ ] **Step 5:** Commit „News: Fotos verkleinern und Metadaten entfernen (sharp)“. + +## Task 6: Beiträge (`newsPosts`-Service) + +**Files:** +- Create: `server/services/newsPosts.js` +- Test: `test/newsPosts.test.js` + +**Interfaces:** +- Consumes: `config.collections.newsPosts`, Task 5 `claimNewsImage/deleteNewsImage`. +- Produces: + - `DURATIONS = ['1w','2w','1m','3m','open','date']`, `PAPERS`, `TYPES` + - `visibleUntilFor(duration, from: Date, dateIso?: 'YYYY-MM-DD') → Date|null` (`1w` +7 Tage, `2w` +14, `1m`/`3m` Kalendermonate wie `addMonths` in stripeBilling, `open` ⇒ null, `date` ⇒ 23:59:59.999 Europe/Berlin des Datums; wirft 400 wenn `date` < morgen oder > heute+1 Jahr) + - `isHanging(post, now: Date) → boolean` + - `validatePostInput(input) → { type, title, text, paper, duration, date?, imageId? }` (wirft `{status:400, field, message}`; `note` braucht text, `photo` braucht imageId, `combo` beides) + - `listPosts(projectId) → Promise<{ hanging: Post[], expired: Post[] }>` (neueste zuerst) + - `createPost({ customerId, projectId, input, now }) → Promise` + - `updatePost({ customerId, projectId, id, input, now }) → Promise` (input kann auch `{ action: 'takeDown' }` oder `{ action: 'rehang', duration, date? }` sein) + - `deletePost({ customerId, projectId, id }) → Promise` (löscht Bild mit) + - `publicPage(projectId, page: number, now) → Promise<{ posts: PublicPost[], hasMore }>` mit `PublicPost = { id, type, title, text, paper, date: 'YYYY-MM-DD', image: { w, h }|null }`, 6 pro Seite + +- [ ] **Step 1: Failing tests:** + - `ablauf_grenzen_berlin`: `visibleUntilFor('date', now, '2026-10-25')` === `2026-10-25T22:59:59.999Z` (Winterzeit-Umstellung am 25.10.); `isHanging` bei `until − 1 ms` true, bei `until` false; `open` hängt bis `takenDownAt`. + - `dauer_werte`: `1w` = +7 Tage, `1m` vom 31.01. ⇒ 28./29.02. wie `addMonths`. + - `datum_grenzen_400`: heute und heute+1 Jahr+1 Tag ⇒ 400. + - `validierung`: Titel 61 Zeichen ⇒ 400 field `title`; `photo` ohne imageId ⇒ 400 field `imageId`; unbekannte paper ⇒ 400. + - `abnehmen_und_wieder_aufhaengen`: takeDown ⇒ in `expired`; rehang mit `2w` ⇒ wieder in `hanging`, neues `visibleFrom`. + - `oeffentlich_nur_haengende_ohne_interna`: 8 hängende + 2 abgelaufene ⇒ Seite 1: 6 Posts, `hasMore true`, keine Schlüssel `customerId|projectId|imageId|takenDownAt`; Seite 2: 2 Posts. + - `fremder_kunde_kann_nicht_aendern`: `updatePost` mit anderer customerId ⇒ 403. +- [ ] **Step 2:** Tests → FAIL. +- [ ] **Step 3:** Implementieren; Berlin-Tagesende über die vorhandenen Helfer in `server/services/deadlines.js` bzw. `berlinTime.js` (gleiche Logik wie `withdrawalDeadline`-Tagesende), kein eigener Zeitzonen-Code. +- [ ] **Step 4:** Tests → PASS. +- [ ] **Step 5:** Commit „News: Beiträge, Anzeigedauer und öffentliche Seitenansicht“. + +## Task 7: API für Kunden und Öffentlichkeit + +**Files:** +- Create: `server/routes/news.js`, `server/routes/publicNews.js` +- Modify: `server/index.js` (Router; `publicNews` **vor** `sessionMiddleware`-abhängigen Routen, eigenes CORS), Job `purgeUnclaimedNewsImages` stündlich neben `purgeChat` +- Test: `test/newsRoutes.test.js` + +**Interfaces:** +- Consumes: Task 3 `getFeatureDoc`, `assertOwnProject`, `findActiveBySiteKey`; Task 5; Task 6. +- Produces: + - Kunde (`requireSession`, Feature muss `active` sein, sonst 409 `{ error: 'News ist für diese Website nicht aktiv.' }`): + `GET /api/news?projectId=` ⇒ `{ hanging, expired, liveUrl }`; `POST /api/news` ⇒ 201 Post; `PATCH /api/news/:id`; `DELETE /api/news/:id` ⇒ 204; + `POST /api/news/image?projectId=` (`application/octet-stream`, `express.raw` limit 10 MB) ⇒ 201 `{ imageId, w, h }`; + `GET /api/news/image/:projectId/:imageId-(l|s).webp` (Vorschau im Editor, nur eigener Kunde). + Schreib-Limit 30/h pro Kunde (`429 { error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' }`). + - Öffentlich: `GET /api/public/news/:siteKey?page=` ⇒ `publicPage` mit `image.src = /api/public/news//img/.webp` und `srcSmall …/-s.webp`; Feature nicht `active` oder unbekannter siteKey ⇒ `200 { posts: [], hasMore: false }`; Header `Cache-Control: public, max-age=60`, `Access-Control-Allow-Origin: *`, `ETag`; Appwrite-Fehler ⇒ 503. + `GET /api/public/news/:siteKey/img/:file` ⇒ Bild nur wenn Beitrag hängt, sonst 404; `Cache-Control: public, max-age=86400`, `X-Content-Type-Options: nosniff`, CORS `*`. + Limit 120/min pro IP ⇒ 429. + +- [ ] **Step 1: Failing tests:** + - `kunde_crud`: aktives Feature ⇒ POST 201, GET listet, PATCH takeDown, DELETE 204. + - `nicht_aktiv_409`: Feature `setup` ⇒ POST 409 mit Text. + - `upload_und_vorschau`: POST image 201; GET Vorschau mit Cookie 200 `image/webp`, mit fremdem Cookie 403. + - `oeffentlich_cors_cache`: GET public ⇒ 200, Header `access-control-allow-origin: *`, `cache-control: public, max-age=60`; Body ohne interne Schlüssel (JSON-String enthält weder `c1` noch Projekt-ID). + - `oeffentlich_unbekannt_leer`: unbekannter siteKey ⇒ 200 `{ posts: [], hasMore: false }`. + - `bild_nur_wenn_haengend`: abgenommener Beitrag ⇒ Bild 404. + - `rate_limit_schreiben`: 31. POST in einer Stunde ⇒ 429. +- [ ] **Step 2:** Tests → FAIL. +- [ ] **Step 3:** Implementieren. +- [ ] **Step 4:** `npm test` grün. +- [ ] **Step 5:** Commit „News: Kunden-API und öffentliche Lese-API“. + +## Task 8: Admin – News je Projekt schalten + +**Files:** +- Modify: `server/routes/admin/customers.js` (neue Routen), `public/admin.js` + `public/admin.html` (Kundenansicht) +- Test: `test/funktionenRoutes.test.js` (Admin-Fälle) + +**Interfaces:** +- Consumes: Task 3 `listCustomerFeatures`, `adminActivate`, `disable`. +- Produces: `GET /api/admin/customers/:customerId/funktionen` ⇒ wie `/api/funktionen`, zusätzlich `ticketWoid`, `adminNote`; `POST /api/admin/customers/:customerId/funktionen/news` body `{ projectId, action: 'activate'|'disable', note? }` ⇒ 200 Doc. `requireAdminSession`. `by` = Admin-Name aus der Session, landet in `adminNote` mit Zeitstempel. Zustand `setup` mit nicht mehr auffindbarem Ticket ⇒ Feld `ticketMissing: true`, Admin zeigt „Ticket fehlt“. + +- [ ] **Step 1: Failing tests** `admin_aktivieren_ohne_zahlung` (⇒ `active`, kein Ticket), `admin_abschalten` (⇒ `disabled`, öffentliche API liefert leer), `kunde_darf_admin_route_nicht` (customerCookie ⇒ 403), `ticket_fehlt_markiert` (workorder gelöscht ⇒ `ticketMissing true`). +- [ ] **Step 2:** Tests → FAIL. +- [ ] **Step 3:** Routen + in `admin.html` Kundenansicht Abschnitt „Funktionen“: Tabelle Projekt · News-Zustand · Ticket · Knöpfe „Aktivieren (ohne Zahlung)“ / „Abschalten“ mit Rückfrage in der Zeile. +- [ ] **Step 4:** Tests grün; Playwright-Check `admin_funktionen` auf Fixture-Server (Knopf schaltet Zustand, Zeile aktualisiert). +- [ ] **Step 5:** Commit „Admin: News je Projekt aktivieren und abschalten“. + +## Task 9: Portal-Oberfläche – Umbau, Funktionen, Editor + +**Files:** +- Modify: `public/dashboard.html` (Nav, `#view-projects`, neue Sections `#view-funktionen`, `#view-news`, `#view-activity` entfernen), `public/app.js` (`VIEW_HASHES`, `viewLoaders`, `APP_PARENT`, `APP_PARENT_LABEL`, Aufruf von `loadCustomerActivity` in Projekte), `public/kundenbereich.css` (Sektion unter Projekte) +- Create: `public/funktionen.js` (ES-Modul wie `chat-composer.js`), `public/funktionen.css` +- Test: Playwright-Skript `test/ui/funktionen.ui.mjs` gegen einen Fixture-Server (statischer Server für `public/` plus API-Fixtures als JSON, wie bei den bisherigen Portal-UI-Checks) + +**Interfaces:** +- Consumes: `GET /api/funktionen`, `POST /api/funktionen/news/checkout`, Task 7 Kunden-API. +- Produces: `initFunktionen(rootEl)`, `initNews(rootEl, { projectId })` aus `public/funktionen.js`; `VIEW_HASHES.funktionen = 'funktionen'`, `VIEW_HASHES.news = 'news'`, `APP_PARENT.news = 'funktionen'`, `APP_PARENT_LABEL.funktionen = 'Funktionen'`; `activity` aus `VIEW_HASHES`/`APP_PARENT_LABEL` entfernt, `hashToView('fortschritt')` ⇒ `'projects'` + `scrollIntoView` auf `#pj-activity`. + +UI-Vorgaben (aus Spec A/B1/B2 und Entwurf `/root/reports/emmas-news-entwurf/pinnwand-entwurf.html` – Editor-CSS von dort übernehmen, Farben auf Portal-Tokens mappen): +- Projekte: Sektion `
` „Was wir zuletzt erledigt haben“, 3 neueste Aufträge, Knopf „Alle anzeigen“. +- Funktionen-Kacheln: News (Pille Aus/In Einrichtung/Aktiv; Knopf „Einrichten lassen · 30 €“ / „Status ansehen“ / „Beiträge schreiben“; bei mehreren Projekten „aktiv für N von M Websites“), Terminplaner und Analyse mit Pille „Kommt bald“. +- News `off`: Leistungsbeschreibung, `setupEstimate`, Projektauswahl, Checkbox mit `consentText`, Knopf „Zahlungspflichtig bestellen · 30 €“ (deaktiviert ohne Haken). Nach Rückkehr `?bestellt=1`: Hinweis „Danke! Sobald die Zahlung da ist, legen wir los.“ +- News `setup`: Statuskarte (Ticketnummer, Bestelldatum, Zeiteinschätzung, „Rechnung ansehen“, „Frage dazu? Chat öffnen“). +- News `active`: Editor wie Entwurf inkl. Anzeigedauer-Auswahl (Vorauswahl „1 Monat“), Listen „Hängt gerade“/„Abgelaufen“, „Auf meiner Website ansehen“, Löschen mit Rückfrage in der Zeile. + +- [ ] **Step 1: Failing UI-Checks** in `test/ui/funktionen.ui.mjs` (Desktop 1280 und Handy 390): + - `nav_ohne_fortschritt`: Nav-Texte exakt „Projekte, Funktionen, Rechnungen, Chat, Verträge“. + - `fortschritt_link`: `dashboard.html#fortschritt` ⇒ Projekte aktiv, `#pj-activity` im Viewport. + - `kacheln_drei_zustaende`: Fixtures off/setup/active ⇒ jeweils richtige Pille und Knopftext. + - `bestellen_braucht_haken`: Knopf deaktiviert, nach Haken aktiv, Klick ruft Checkout-Fixture und navigiert zur `url`. + - `editor_vorschau_live`: Tippen im Titel ändert die Vorschau; Art „Text“ blendet Fotofeld aus. + - `editor_handy_unterseite`: auf 390 px öffnet News als Unterseite mit „Zurück zu Funktionen“. + - `kein_horizontaler_scroll`: `scrollWidth <= innerWidth` auf allen Ansichten bei 390 px. +- [ ] **Step 2:** Checks → FAIL. +- [ ] **Step 3:** Implementieren. +- [ ] **Step 4:** Checks grün; `npm test` grün; Screenshots der 6 Hauptansichten nach `/root/reports/funktionen-news-ui/` für den Nutzer. +- [ ] **Step 5:** Commit „Portal: Fortschritt unter Projekte, neuer Tab Funktionen mit News-Editor“. + +## Task 10: Einbau-Skript für Kundenwebsites + +**Files:** +- Create: `public/embed/news.js`, `public/embed/news-demo.html` (Testseite, von nginx/Express nicht verlinkt), `docs/funktionen/news-einbau.md` (Anleitung für das Team: div, Skript, CSS-Variablen, Datenschutz-Absatz) +- Modify: `server/index.js` nur falls `public/embed/` eigene Header braucht (`Cache-Control: public, max-age=300`, CORS `*`) +- Test: `test/ui/embed.ui.mjs` + +**Interfaces:** +- Consumes: Task 7 öffentliche API. +- Produces: `
…statischer Leerzustand…
` + ``; CSS-Variablen `--wkn-board, --wkn-frame, --wkn-font-display, --wkn-font-body, --wkn-paper-sun, --wkn-paper-sky, --wkn-paper-coral, --wkn-paper-white` mit Kork/Holzrahmen `#A8733F` als Standard; alle Klassen mit Präfix `wkn-`, Styles in einem eingefügten `