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

32 KiB
Raw Blame History

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=""> + 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).