Plan: Funktionen-Tab und News-Pinnwand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:21:21 +00:00
1 parent 586e3cc85d
commit c55e6f2dfe
1 file changed
+322
@@ -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 <noreply@anthropic.com>`; 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 (`<img onerror>`) 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<string priceId>`.
- [ ] **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<Array<{ projectId, projectName, liveUrl, features: { news: FeatureView } }>>` 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<doc|null>`
- `assertOwnProject(customerId, projectId) → Promise<project>` (wirft `{status:403}`)
- `markOrdered({ customerId, projectId, key, sessionId, consent }) → Promise<doc>`
- `markPaid({ sessionId, invoiceId, invoiceUrl }) → Promise<{ doc, created: boolean }>` (idempotent: bereits `setup`/`active` ⇒ `created:false`)
- `markCheckoutFailed(sessionId) → Promise<doc|null>` (⇒ `off`)
- `activate(docId, { by }) → Promise<doc>`, `disable(docId, { by }) → Promise<doc>`, `adminActivate(customerId, projectId, key, { by }) → Promise<doc>`
- `activateClosedTickets(now?) → Promise<Array<doc>>` (alle `setup` mit Ticket-Status `'Closed'` ⇒ `active`)
- `newSiteKey() → string` (16 Zeichen base32)
- `findActiveBySiteKey(siteKey) → Promise<doc|null>`
- in `ticketAdmin.js`: `createFeatureTicket({ customerId, project, featureKey, siteKey }) → Promise<{ ticketId, woid }>` (Felder wie `ensureProjectTicket`: `topic` `"News-Pinnwand einbauen – <projectName>"`, `type 'Webpage'`, `status 'Open'`, `priority 1`, `serviceType 'Remote'`, `systemType 'n/a'`, `details` mit Projekt, Domain, siteKey und Einbau-Hinweis „<div data-webklar-news=\"<siteKey>\"> + 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_<projectId>_<key>` (≤ 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/<projectId>/<imageId>.webp`, `<imageId>-s.webp`, Sidecar `<imageId>.json` `{ customerId, projectId, createdAt, claimed:false, w, h }`; wirft `{status:413}` bei Größe, `{status:415}` bei Typ)
- `claimNewsImage(projectId, imageId) → Promise<void>`, `deleteNewsImage(projectId, imageId) → Promise<void>`
- `newsImagePath(projectId, imageId, size: 'l'|'s') → string|null` (nur `[a-z0-9-]`-IDs, sonst null)
- `purgeUnclaimedNewsImages(now?) → Promise<number>` (ä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<Post>`
- `updatePost({ customerId, projectId, id, input, now }) → Promise<Post>` (input kann auch `{ action: 'takeDown' }` oder `{ action: 'rehang', duration, date? }` sein)
- `deletePost({ customerId, projectId, id }) → Promise<void>` (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/<siteKey>/img/<id>.webp` und `srcSmall …/<id>-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 `<section id="pj-activity">` „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: `<div data-webklar-news="<siteKey>" data-api="https://project.webklar.com">…statischer Leerzustand…</div>` + `<script src="https://project.webklar.com/embed/news.js" defer></script>`; 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 `<style id="wkn-style">` (einmalig).
- [ ] **Step 1: Failing checks** gegen `news-demo.html` mit gemockter API:
- `leer_bleibt_leerzustand`: 0 Posts ⇒ statischer Leerzustand sichtbar.
- `ein_beitrag_neu_schild`: 1 Post ⇒ 1 Zettel mit „Neu“.
- `acht_beitraege_aeltere`: 8 Posts ⇒ 6 sichtbar, Knopf „Ältere Neuigkeiten ansehen“ lädt 2 nach und verschwindet.
- `xss_als_text`: Titel `<img src=x onerror=window.__x=1>` ⇒ als Text sichtbar, `window.__x` undefined.
- `api_down_kein_fehler`: API 503 ⇒ Leerzustand, keine Konsolen-Exception.
- `ohne_js`: JS aus ⇒ Leerzustand sichtbar.
- `handy_wischreihe`: 390 px ⇒ Zettel in horizontaler Scroll-Snap-Reihe, Seite ohne horizontalen Scroll.
- `keine_cookies_storage`: nach Laden `document.cookie === ''`, `localStorage.length === 0`.
- [ ] **Step 2:** Checks → FAIL.
- [ ] **Step 3:** Implementieren (Vanilla, keine Abhängigkeiten, < 15 KB unkomprimiert), Aufbau der Zettel aus dem Entwurf; Text nur über `textContent`.
- [ ] **Step 4:** Checks grün.
- [ ] **Step 5:** Commit „Einbau-Skript news.js für Kundenwebsites“.
## Task 11: Aufbewahrung und Rechtstexte (Entwürfe)
**Files:**
- Modify: `server/services/retention.js` (`newsPosts` + Bilder in den Löschplan bei Kunden, deren Frist abläuft; Features auf `disabled`), `public/datenschutz.html` (neuer Abschnitt „News-Beiträge und Fotos“ mit Datum)
- Create: `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (Leistungsbeschreibung „Funktionen – News-Pinnwand“: Leistung, Preis 30 € einmalig, Einbau durch WEBklar, Zeiteinschätzung ohne Zusage, Nutzung kostenlos, Abschalten bei Vertragsende, Widerruf), Verlinkung auf der Buchungsseite aus Task 9
- Create: `docs/funktionen/website-datenschutz-absatz.md` (Mustertext für Kundenwebsites)
- Test: `test/retention.test.js`, `test/legalTexts.test.js`
**Interfaces:**
- Consumes: `findDueDeletions/applyDeletions` bestehend; Task 5 `deleteNewsImage`; Task 3 `disable`.
- [ ] **Step 1: Failing tests** `retention_loescht_news` (Kunde mit abgelaufener Frist ⇒ Plan enthält seine newsPosts + Bilder, nach `applyDeletions` weg, Feature `disabled`) und `legal_datenschutz_news` (Seite enthält Überschrift „News-Beiträge und Fotos“ und das Wort „Metadaten“; Leistungsbeschreibung enthält „30,00 €“ und „§ 356 Abs. 4 BGB“).
- [ ] **Step 2:** Tests → FAIL.
- [ ] **Step 3:** Implementieren; alle Texte als **Entwurf** markiert (`<!-- ENTWURF, Freigabe Nutzer ausstehend -->`), Du-Form, keine Gedankenstriche im sichtbaren Kundentext der Website-Mustertexte.
- [ ] **Step 4:** `npm test` grün.
- [ ] **Step 5:** Commit „News: Aufbewahrung, Datenschutz-Abschnitt und Leistungsbeschreibung (Entwurf)“. **Stopp:** Texte dem Nutzer zur Freigabe vorlegen, bevor der Branch nach main geht.
## Nachlauf (nicht Teil des Branches, je mit OK des Nutzers)
1. Schema live anlegen: `docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs`.
2. Push `feature/funktionen-news:main` (deploy.sh installiert `sharp`); Live-Checks: `/api/funktionen` 401 ohne Login, `/embed/news.js` 200, `/api/public/news/unbekannt` 200 leer, Logs ohne Fehler.
3. Pilot Emma's Schatzkiste: Admin aktiviert News für das Projekt; im Repo `WEBklar/emmas-schatzkiste` Sektion „Neues aus dem Laden“ nach dem Hero + Nav-Punkt + Datenschutz-Absatz (eigener Commit dort).