32 KiB
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;datefrü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) auscrypto.randomBytes.- Kundentexte: Du-Form wie das übrige Portal; im Einbau-Skript nie
innerHTMLmit 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
- SEPA zahlt verzögert:
checkout.session.completedmitpayment_status: 'unpaid'darf nicht freischalten und kein Ticket anlegen; erstcheckout.session.async_payment_succeeded→ Task 4 Testsepa_wartet_auf_zahlung. - Doppelte Webhooks / doppelte Buchung: zweiter Webhook legt kein zweites Ticket an; zweiter Checkout-Versuch bei
setup/active→ 409 → Task 4 Testswebhook_idempotent,checkout_bei_setup_409. - Beitrag genau an der Ablaufgrenze und über Mitternacht in Berlin:
isHanginganvisibleUntil - 1 mstrue, anvisibleUntilfalse;date= Ende des Berliner Tages auch in der Sommerzeit-Umstellungswoche → Task 6 Testablauf_grenzen_berlin. - Handy-Foto mit GPS und Hochkant-EXIF: Ausgabe ohne EXIF/GPS, richtig gedreht → Task 5 Test
exif_gps_entfernt_und_gedreht. - Kundentext mit HTML (
<img onerror>) auf der Website: wird als Text angezeigt → Task 10 Checkxss_als_text.
Task 1: Schema portalFeatures + newsPosts
Files:
- Modify:
scripts/setup-abo-collections.mjs(vorconsole.log('Fertig.')) - Modify:
server/config.js(Collection-IDnewsPosts) - Test:
test/support.test.js(Schema-Schlüssel vorhanden)
Interfaces:
-
Produces:
config.collections.newsPosts(envAPPWRITE_COLLECTION_NEWS_POSTS, Default'newsPosts');schemaKeys('portalFeatures'),schemaKeys('newsPosts')für alle späteren Tests. -
Step 1: Failing test
schema_funktionen_newsintest/support.test.js:schemaKeys('portalFeatures')enthältcustomerId, projectId, featureKey, enabled, unlockedByPurchase, purchaseStatus, state, siteKey, ticketId, stripeSessionId, stripeInvoiceId, invoiceUrl, orderedAt, activatedAt, disabledAt, consentText, consentAt, consentIp, consentUserAgent, adminNote;schemaKeys('newsPosts')enthältcustomerId, 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),invoiceUrl500,consentText1000,consentIp64,consentUserAgent300,adminNote300; booleanenabled,unlockedByPurchase; datetimeorderedAt, activatedAt, disabledAt, consentAt. Indexe:idx_customer(customerId),idx_state(state),idx_sitekeyunique (siteKey).newsPosts(Name „News-Beiträge“): IDs 64,type8,title60,text400,imageId64,paper8,duration8, datetimevisibleFrom, visibleUntil, takenDownAt, createdAt, updatedAt. Indexidx_project_from(projectId, visibleFrom). Kopfkommentar des Skripts um beide Collections ergänzen. -
Step 4: Test → PASS;
npm testkomplett grün. -
Step 5: Commit „Schema: portalFeatures-Zustände und newsPosts“.
Task 2: Einmal-Preis „News-Einrichtung“
Files:
- Modify:
server/config.js(nebenmailStorage),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 legtensurePrice('newsSetup')einen Preis ohnerecurringmitunit_amount 3000,lookup_key 'webklar_news_einrichtung_3000', Produkt-Metadatenwebklar: 'news-einrichtung'an; zweiter Aufruf erzeugt keinen zweiten Preis. -
Step 2: Test → FAIL.
-
Step 3:
PRODUCT_METADATA.newsSetup = 'news-einrichtung';catalogFor('newsSetup')→config.newsSetup; inensurePricerecurringnur setzen, wenn!catalog.oneTime.scripts/stripe-products.mjslistet 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(lieststate) - Test:
test/siteFeatures.test.js
Interfaces:
-
Consumes:
config.collections.portalFeatures,websiteProjects,workorders,customers;listDocuments/getDocument/createDocument/updateDocumentausappwriteAdmin.js. -
Produces:
FEATURE_KEYS = ['news'],STATES = ['off','ordered','setup','active','disabled']listCustomerFeatures(customerId) → Promise<Array<{ projectId, projectName, liveUrl, features: { news: FeatureView } }>>mitFeatureView = { 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: bereitssetup/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>>(allesetupmit 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 wieensureProjectTicket:topic"News-Pinnwand einbauen – <projectName>",type 'Webpage',status 'Open',priority 1,serviceType 'Remote',systemType 'n/a',detailsmit 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 mitschemaKeysfür portalFeatures, websiteProjects, workorders, customers):ohne_dokument_ist_off: Kunde mit 2 Projekten ⇒ 2 Einträge, beidestate 'off'.bezahlt_legt_ticket_an:markOrdered→markPaid⇒state 'setup',ticketIdgesetzt, genau 1 workorder mittopicbeginnend „News-Pinnwand einbauen“.webhook_idempotent: zweitesmarkPaidmit gleicher Session ⇒created:false, weiterhin 1 workorder.ticket_geschlossen_aktiviert: workorderstatus 'Closed'⇒activateClosedTickets()liefert 1 Doc,state 'active',enabled true,activatedAtgesetzt; Ticket'Open'⇒ bleibtsetup.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 wieeventIdFor).siteKeywird beim ersten Anlegen vergeben und nie geändert.routes/features.jsliefert zusätzlichstate. -
Step 4: Tests → PASS;
npm testgrü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/checkoutbody{ projectId, consent: true }⇒200 { url }|400ohne consent |403fremdes Projekt |409beiordered(nicht abgelaufen)/setup/activecreateFeatureCheckout(customer, { projectId, key, consentAt }) → Promise<{ id, url }>:mode 'payment',line_items [{ price, quantity 1 }],invoice_creation.enabled true,payment_method_typeswie 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.completedmitpayment_status 'paid'undcheckout.session.async_payment_succeeded⇒markPaid+ beicreatedK15 an Kunden, T16 ans Team;checkout.session.async_payment_failed/checkout.session.expired⇒markCheckoutFailed; beiasync_payment_failedzusätzlich Team-Mail T16 mit Blockzahlung_fehlgeschlagen. Der Abo-Zweig bleibt unverändert (Bedingungobj.mode !== 'subscription'steht schon da). - Job in
server/index.js:setInterval(runActivation, 5 * 60 * 1000).unref();runActivation=activateClosedTickets()+ K16 je aktiviertem Doc. Zusätzlich ruftGET /api/funktionenvor der AntwortactivateClosedTickets()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/funktionenund/api/abo, fakeStripe, mailSink):liste_zustaende: GET mit customerCookie ⇒ 200, Projekte mitfeatures.news.state 'off',setupEstimategesetzt.checkout_ohne_zustimmung_400,checkout_fremdes_projekt_403.checkout_legt_session_an: 200{ url }; fakeStripe hat Session mitmode 'payment', Betrag 3000, MetadatenfeatureKey 'news'; Docstate 'ordered',consentAt+consentTextgespeichert.checkout_bei_setup_409.sepa_wartet_auf_zahlung: Webhookcheckout.session.completedmitpayment_status 'unpaid'⇒ weiterhinordered, 0 workorders, 0 Mails; danachasync_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 aufClosed,runActivation()⇒active+ K16 an Kunden.
-
Step 2: Tests → FAIL.
-
Step 3: Implementieren;
runActivationaussiteFeatures.jsexportieren, damit der Test ihn direkt aufruft. -
Step 4:
npm testgrün;node --test test/mailTemplates.test.jserkennt die drei neuen Vorlagen ohneproblems. -
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.shnur fallsnpm cidort Plattform-Binaries braucht (prüfen, dassdeploy.shnpm installim Container ausführt) - Test:
test/newsImages.test.js
Interfaces:
-
Produces:
NEWS_IMAGE_MAX_BYTES = 10 * 1024 * 1024storeNewsImage({ customerId, projectId, buffer }) → Promise<{ imageId, w, h }>(schreibtupload-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 undclaimed:false)newsImageRateLimited(customerId) → boolean(20/h)
-
Step 1: Failing tests:
exif_gps_entfernt_und_gedreht: Eingabe 1200×800 mit Orientation 6 + GPS ⇒ Ausgabesharp(...).metadata()hat keineexif, Breite < Höhe.verkleinert_auf_1600_und_480: 4000×3000 ⇒ groß 1600×1200, klein 480×360, beideformat '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 mitcreatedAt−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 übersharp().metadata().format∈jpeg|png|webp|heif. Upload-Wurzel wiechatAttachments.jsausconfig. -
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 5claimNewsImage/deleteNewsImage. -
Produces:
DURATIONS = ['1w','2w','1m','3m','open','date'],PAPERS,TYPESvisibleUntilFor(duration, from: Date, dateIso?: 'YYYY-MM-DD') → Date|null(1w+7 Tage,2w+14,1m/3mKalendermonate wieaddMonthsin stripeBilling,open⇒ null,date⇒ 23:59:59.999 Europe/Berlin des Datums; wirft 400 wenndate< morgen oder > heute+1 Jahr)isHanging(post, now: Date) → booleanvalidatePostInput(input) → { type, title, text, paper, duration, date?, imageId? }(wirft{status:400, field, message};notebraucht text,photobraucht imageId,combobeides)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 }>mitPublicPost = { 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.);isHangingbeiuntil − 1 mstrue, beiuntilfalse;openhängt bistakenDownAt.dauer_werte:1w= +7 Tage,1mvom 31.01. ⇒ 28./29.02. wieaddMonths.datum_grenzen_400: heute und heute+1 Jahr+1 Tag ⇒ 400.validierung: Titel 61 Zeichen ⇒ 400 fieldtitle;photoohne imageId ⇒ 400 fieldimageId; unbekannte paper ⇒ 400.abnehmen_und_wieder_aufhaengen: takeDown ⇒ inexpired; rehang mit2w⇒ wieder inhanging, neuesvisibleFrom.oeffentlich_nur_haengende_ohne_interna: 8 hängende + 2 abgelaufene ⇒ Seite 1: 6 Posts,hasMore true, keine SchlüsselcustomerId|projectId|imageId|takenDownAt; Seite 2: 2 Posts.fremder_kunde_kann_nicht_aendern:updatePostmit anderer customerId ⇒ 403.
-
Step 2: Tests → FAIL.
-
Step 3: Implementieren; Berlin-Tagesende über die vorhandenen Helfer in
server/services/deadlines.jsbzw.berlinTime.js(gleiche Logik wiewithdrawalDeadline-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;publicNewsvorsessionMiddleware-abhängigen Routen, eigenes CORS), JobpurgeUnclaimedNewsImagesstündlich nebenpurgeChat - Test:
test/newsRoutes.test.js
Interfaces:
-
Consumes: Task 3
getFeatureDoc,assertOwnProject,findActiveBySiteKey; Task 5; Task 6. -
Produces:
- Kunde (
requireSession, Feature mussactivesein, 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.rawlimit 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=⇒publicPagemitimage.src = /api/public/news/<siteKey>/img/<id>.webpundsrcSmall …/<id>-s.webp; Feature nichtactiveoder unbekannter siteKey ⇒200 { posts: [], hasMore: false }; HeaderCache-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.
- Kunde (
-
Step 1: Failing tests:
kunde_crud: aktives Feature ⇒ POST 201, GET listet, PATCH takeDown, DELETE 204.nicht_aktiv_409: Featuresetup⇒ POST 409 mit Text.upload_und_vorschau: POST image 201; GET Vorschau mit Cookie 200image/webp, mit fremdem Cookie 403.oeffentlich_cors_cache: GET public ⇒ 200, Headeraccess-control-allow-origin: *,cache-control: public, max-age=60; Body ohne interne Schlüssel (JSON-String enthält wederc1noch 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 testgrü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ätzlichticketWoid,adminNote;POST /api/admin/customers/:customerId/funktionen/newsbody{ projectId, action: 'activate'|'disable', note? }⇒ 200 Doc.requireAdminSession.by= Admin-Name aus der Session, landet inadminNotemit Zeitstempel. Zustandsetupmit nicht mehr auffindbarem Ticket ⇒ FeldticketMissing: 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.htmlKundenansicht 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_funktionenauf 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-activityentfernen),public/app.js(VIEW_HASHES,viewLoaders,APP_PARENT,APP_PARENT_LABEL, Aufruf vonloadCustomerActivityin Projekte),public/kundenbereich.css(Sektion unter Projekte) - Create:
public/funktionen.js(ES-Modul wiechat-composer.js),public/funktionen.css - Test: Playwright-Skript
test/ui/funktionen.ui.mjsgegen einen Fixture-Server (statischer Server fürpublic/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 })auspublic/funktionen.js;VIEW_HASHES.funktionen = 'funktionen',VIEW_HASHES.news = 'news',APP_PARENT.news = 'funktionen',APP_PARENT_LABEL.funktionen = 'Funktionen';activityausVIEW_HASHES/APP_PARENT_LABELentfernt,hashToView('fortschritt')⇒'projects'+scrollIntoViewauf#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 mitconsentText, 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-activityim 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 zururl.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 <= innerWidthauf allen Ansichten bei 390 px.
-
Step 2: Checks → FAIL.
-
Step 3: Implementieren.
-
Step 4: Checks grün;
npm testgrü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.jsnur fallspublic/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-whitemit Kork/Holzrahmen#A8733Fals Standard; alle Klassen mit Präfixwkn-, Styles in einem eingefügten<style id="wkn-style">(einmalig). -
Step 1: Failing checks gegen
news-demo.htmlmit 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.__xundefined.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 Ladendocument.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 aufdisabled),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/applyDeletionsbestehend; Task 5deleteNewsImage; Task 3disable. -
Step 1: Failing tests
retention_loescht_news(Kunde mit abgelaufener Frist ⇒ Plan enthält seine newsPosts + Bilder, nachapplyDeletionsweg, Featuredisabled) undlegal_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 testgrü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)
- Schema live anlegen:
docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs. - Push
feature/funktionen-news:main(deploy.sh installiertsharp); Live-Checks:/api/funktionen401 ohne Login,/embed/news.js200,/api/public/news/unbekannt200 leer, Logs ohne Fehler. - Pilot Emma's Schatzkiste: Admin aktiviert News für das Projekt; im Repo
WEBklar/emmas-schatzkisteSektion „Neues aus dem Laden“ nach dem Hero + Nav-Punkt + Datenschutz-Absatz (eigener Commit dort).