Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-30-funktionen-news.md
T
2026-09-30 16:21:21 +00:00

323 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).