Compare commits

..
20 Commits
Author SHA1 Message Date
knsoandClaude Opus 5.5 4b7f73abba Funktionen/News: Schlussdurchgang (Webhook-Retry, Checkout-Sitzung, Rechnungslink, Widerruf Funktion, UI- und Rechtstext-Korrekturen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:38:15 +00:00
knsoandClaude Opus 5.5 0dc57fe5e8 News: Aufbewahrung, Datenschutz-Abschnitt und Leistungsbeschreibung (Entwurf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:23:41 +00:00
knsoandClaude Opus 5.5 0e0776b406 news.js: Variablen-Defaults ohne Spezifitaet, Combo-Umbruch, Bildmaße absichern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:19:40 +00:00
knsoandClaude Opus 5.5 035d1d5517 Einbau-Skript news.js für Kundenwebsites
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:16:37 +00:00
knsoandClaude Opus 5.5 5a09d060a1 Portal: Fortschritt unter Projekte, neuer Tab Funktionen mit News-Editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:06:02 +00:00
knsoandClaude Opus 5.5 6dac6aa073 Admin: News je Projekt aktivieren und abschalten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:56:41 +00:00
knsoandClaude Opus 5.5 a421308019 News: Cache-Buster für Bild-URLs, HEAD, Limiter-Feinschliff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:52:47 +00:00
knsoandClaude Opus 5.5 319f8937aa News: Kunden-API und öffentliche Lese-API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:49:39 +00:00
knsoandClaude Opus 5.5 d320730251 News: Beiträge – Titel optional, Datumsprüfung, Bildregeln
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:45:42 +00:00
knsoandClaude Opus 5.5 434bab0b7a News: Beiträge, Anzeigedauer und öffentliche Seitenansicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:43:24 +00:00
knsoandClaude Opus 5.5 ad0b08f32a News: Fotos mit Pixelgrenze, sauberer Fehlerzuordnung und mehr Tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:40:24 +00:00
knsoandClaude Opus 5.5 ac7375e889 News: Fotos verkleinern und Metadaten entfernen (sharp)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:38:33 +00:00
knsoandClaude Opus 5.5 8374a570aa Funktionen: Bestellung serialisiert, Sitzung nachtragen, Freischaltung einmalig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:35:59 +00:00
knsoandClaude Opus 5.5 0cc35bf1a5 Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:33:48 +00:00
knsoandClaude Opus 5.5 770cbe32b7 Funktionen: Bestellschutz, serialisiertes markPaid, idx_session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:29:12 +00:00
knsoandClaude Opus 5.5 4b1df8fbfa Funktionen: Zustände, Ticket beim Kauf, Freischaltung bei geschlossenem Ticket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:27:27 +00:00
knsoandClaude Opus 5.5 16fbb03c13 Stripe: Einmalpreis News-Einrichtung 30 €
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:24:21 +00:00
knsoandClaude Opus 5.5 85bee3190c Schema: portalFeatures-Zustände und newsPosts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:24:21 +00:00
knsoandClaude Opus 5.5 c55e6f2dfe Plan: Funktionen-Tab und News-Pinnwand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:21:21 +00:00
knsoandClaude Opus 5.5 586e3cc85d Spec: Funktionen-Tab und News-Pinnwand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:17:25 +00:00
55 changed files with 5930 additions and 54 deletions

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# News-Pinnwand auf der Kundenwebsite einbauen
Anleitung für das WEBklar-Team. Die Pinnwand zeigt die Beiträge, die der Kunde im Portal (Tab „Funktionen“) schreibt. Den `siteKey` findest Du im Portal-Admin beim Projekt (16 Zeichen, a–z und 2–7).
## 1. Die Stelle auf der Seite
```html
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
<!-- statischer Leerzustand, siehe unten -->
</div>
```
- `data-webklar-news`: der siteKey des Projekts (Pflicht).
- `data-api`: Herkunft des Portals. Ohne Angabe nimmt das Skript die Herkunft aus der Adresse des Skripts. Das klappt nur, wenn es als normales `<script src>` geladen wird. Bei Modul-Import, Tag-Manager oder nachgeladenem Skript ist `data-api` Pflicht.
- `data-signature` (optional): Unterschrift unter jedem Zettel, z. B. `data-signature="Opa Martin"`. Ohne Attribut gibt es keine Unterschrift.
## 2. Das Skript (einmal pro Seite, vor `</body>`)
```html
<script src="https://project.webklar.com/embed/news.js" defer></script>
```
Keine Abhängigkeiten, keine Cookies, kein Browser-Speicher. Kundentexte werden nur als Text eingesetzt, nie als HTML. Das Skript wird 5 Minuten gecacht und darf von jeder Domain geladen werden.
## 3. Statischer Leerzustand
Alles, was im `div` steht, gehört der Kundenwebsite. Das Skript ersetzt es nur, wenn die API mindestens einen Beitrag liefert. Bei 0 Beiträgen, Fehler, ausgeschaltetem JavaScript oder unbekanntem Schlüssel bleibt es stehen. Beispiel:
```html
<div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">
<h3>Hier hängt bald was Neues.</h3>
<p>Neuigkeiten findest Du bald an dieser Pinnwand.</p>
</div>
```
Gestalte den Leerzustand im Stil der Kundenseite. Das Skript fasst ihn nicht an.
## 4. Aussehen anpassen (CSS-Variablen)
Setze sie auf dem `div` oder in der Seiten-CSS, z. B. `[data-webklar-news]{ --wkn-frame:#2f5d50; }`.
| Variable | Standard |
| --- | --- |
| `--wkn-board` (Korkfläche) | `#C08A52` |
| `--wkn-frame` (Holzrahmen) | `#A8733F` |
| `--wkn-font-display` (Überschriften) | `"Fredoka", system-ui, sans-serif` |
| `--wkn-font-body` (Text) | `"Nunito", system-ui, sans-serif` |
| `--wkn-paper-sun` | `#FFF6D6` |
| `--wkn-paper-sky` | `#DDEEFB` |
| `--wkn-paper-coral` | `#FFE1DA` |
| `--wkn-paper-white` | `#fff` |
Die Schriften lädt das Skript nicht selbst. Sind sie auf der Kundenseite nicht eingebunden, greift die Systemschrift. Setze dann die Variablen auf die Schriften der Kundenseite.
Das Layout richtet sich nach der Breite des `div` (Container Query, braucht aktuelle Browser), nicht nach dem Bildschirm: ab 701 px Breite ein Korkbrett mit drei Spalten, darunter eine Wischreihe mit Punkten. Sechs Beiträge pro Seite, „Ältere Neuigkeiten ansehen“ lädt die nächsten nach. Alle Klassen beginnen mit `wkn-`, die Styles stehen einmalig in `<style id="wkn-style">`.
## 5. Datenschutzerklärung des Kunden
Das Skript lädt Beiträge und Bilder von `project.webklar.com`. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung des Kunden braucht deshalb den Absatz aus `docs/funktionen/website-datenschutz-absatz.md`. Bau die Pinnwand erst ein, wenn der Absatz dort steht.
## 6. Testen
`/embed/news-demo.html` auf dem Portal ist eine nicht verlinkte Testseite. Automatische Prüfung: `node test/ui/embed.ui.mjs`.
@@ -0,0 +1,23 @@
# Mustertext: Datenschutzerklärung der Kundenwebsite (News-Pinnwand)
Entwurf, Freigabe Nutzer ausstehend. Der Absatz gehört in die Datenschutzerklärung jeder Kundenwebsite, auf der die News-Pinnwand läuft. Bau die Pinnwand erst ein, wenn er dort steht.
Hinweise für das Team:
- Variante A, wenn WEBklar die Website hostet (Abo). Variante B, wenn die Website woanders liegt und die Pinnwand von `project.webklar.com` lädt.
- Im sichtbaren Text der Kundenwebsite keine Gedankenstriche (Wunsch von Emma's Schatzkiste). Den Text unverändert übernehmen oder ohne Gedankenstriche anpassen.
- Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). `[Überschrift]`-Nummerierung der Website beachten.
- Die Löschfrist von 14 Tagen für Server-Protokolle entspricht Abschnitt 3 der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort die Frist, hier mitändern.
- Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe).
## Variante A: Website wird von WEBklar gehostet
### Neuigkeiten auf dieser Website
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Beim Aufruf der Seite lädt Ihr Browser diese Beiträge und Bilder von dem Server, den die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, in unserem Auftrag betreibt und der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir brauchen diese Daten, um die Beiträge auszuliefern und den Betrieb sicher zu halten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Darstellung aktueller Informationen und im sicheren, funktionsfähigen Betrieb der Website. Die Protokolle werden nach 14 Tagen gelöscht.
## Variante B: Website liegt bei einem anderen Anbieter
### Neuigkeiten auf dieser Website
Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich]
@@ -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).
@@ -0,0 +1,209 @@
# Funktionen-Tab und News-Pinnwand – Design
Stand: 2026-09-30 · Status: vom Nutzer im Chat freigegeben (Abschnitte 1–6), Spec zur Durchsicht
Teilprojekte: **A** Portal-Umbau + **B** News (diese Spec). **C** Analyse mit Cookie-Banner ist ausdrücklich
nicht Teil dieser Spec und bekommt eine eigene.
## Ziel
Kunden können im Kundenportal Zusatzfunktionen für ihre Website buchen. Die erste ist **News**: der
Kunde schreibt im Portal kurze Beiträge (Text, Foto oder beides), die auf seiner Website als Pinnwand
erscheinen – jeder Besucher sieht sie. Referenz für Optik und Bedienung ist der freigegebene Entwurf
„Schatzkiste Pinnwand-Entwurf“ (https://claude.ai/artifact/E1ad9iK7P91Su2GrLKJQMm, Quelle
`/root/reports/emmas-news-entwurf/pinnwand-entwurf.html`, Kork-Hintergrund mit Holzrahmen #A8733F).
## Entscheidungen (vom Nutzer)
| # | Entscheidung |
|---|---|
| E1 | Tab „Fortschritt“ entfällt, sein Inhalt wandert unter „Projekte“. |
| E2 | Neuer Tab heißt **Funktionen**. Später kommen weitere Funktionen dazu (z. B. Terminplaner). |
| E3 | News ist standardmäßig **aus**. **Jeder Kunde** kann es buchen, ein Abo ist nicht nötig. |
| E4 | Preis: **30 € einmalig für den Einbau**, die Nutzung danach ist **kostenlos**. Kein Abo, kein Testmonat. |
| E5 | Mit der Buchung wird ein **Ticket** eröffnet; erst wenn es geschlossen ist, ist News für den Kunden aktiv. Es gibt keine feste Fertigstellungszeit, nur eine grobe Einschätzung. |
| E6 | Anzeigedauer **pro Beitrag**: 1 Woche · 2 Wochen · 1 Monat (Vorauswahl) · 3 Monate · bis ich ihn abnehme · bis zu einem Datum. Abgelaufenes bleibt im Portal unter „Abgelaufen“ und lässt sich wieder aufhängen. |
| E7 | Website zeigt die **neuesten 6** Beiträge, ältere über „Ältere Neuigkeiten ansehen“. |
| E8 | Auslieferung über **Weg 1**: öffentliche Lese-Schnittstelle im Portal + Skript auf der Kundenwebsite. |
| E9 | Admin kann News ohne Zahlung aktivieren (Pilot Emma's Schatzkiste, Kulanz). |
## A. Portal-Umbau
**Navigation** (Seitenleiste am Desktop, Tab-Leiste unten in der App-Ansicht ≤ 880 px):
Projekte · **Funktionen** · Rechnungen · Chat · Verträge.
**Projekte** (`#view-projects`): unter den Projektkarten eine Sektion „Was wir zuletzt erledigt haben“
mit dem bisherigen Inhalt von `loadCustomerActivity` (Karte je Auftrag mit Status, Fortschrittsleiste,
Arbeiten). Standardmäßig die 3 neuesten Aufträge, darunter „Alle anzeigen“. `#view-activity` und der
Nav-Eintrag entfallen. `VIEW_HASHES`/Router: der Hash `#fortschritt` öffnet Projekte und scrollt zur
Sektion (alte Links aus Mails und Lesezeichen). `APP_PARENT_LABEL.activity` entfällt.
**Funktionen** (`#view-funktionen`, Hash `#funktionen`): Kachel-Übersicht, eine Kachel je Funktion.
- **News**: Status-Pille *Aus* / *In Einrichtung* / *Aktiv* und der passende Knopf
(„Einrichten lassen · 30 €“ / „Status ansehen“ / „Beiträge schreiben“).
- **Terminplaner**, **Analyse**: Pille „Kommt bald“, ohne Aktion.
- News öffnet `#view-news` (Hash `#news`); in der App-Ansicht Unterseite mit „Zurück zu Funktionen“
(`APP_PARENT.news = 'funktionen'`), am Desktop normale Ansicht.
- Hat der Kunde mehrere Website-Projekte, gilt News **pro Projekt**; die News-Ansicht hat dann oben eine
Projektauswahl, die Kachel zeigt „aktiv für 1 von 2 Websites“.
## B1. Buchen und Freischalten
**Zustände** je (Kunde, Projekt) im vorhandenen Collection `portalFeatures`
(`featureKey = 'news'`, `projectId`, `enabled`, `unlockedByPurchase`, `purchaseStatus`). Ergänzt werden
Attribute: `state` (`off` | `ordered` | `setup` | `active` | `disabled`), `ticketId`, `stripeSessionId`,
`stripeInvoiceId`, `orderedAt`, `activatedAt`, `siteKey`. `enabled` bleibt als abgeleitetes Feld
(`state === 'active'`) für Rückwärtskompatibilität von `GET /api/features`.
```
off ──Checkout bezahlt──▶ setup ──Ticket geschlossen──▶ active
│ ▲ │
└──Admin „aktivieren“──────┴──────────────────────────────┘ (Admin kann direkt active setzen)
active ──Admin „abschalten“ / Kunde verlässt WEBklar──▶ disabled
```
`ordered` ist der kurze Zustand zwischen Checkout-Start und Zahlungseingang (Checkout verfallen ⇒ zurück
auf `off`).
**Buchungsseite** (in `#view-news`, solange `off`):
- Was der Kunde bekommt: Pinnwand-Sektion im Stil seiner Website, Einbau durch uns, Beiträge danach
jederzeit selbst schreiben, dauerhaft ohne weitere Kosten.
- Grobe Zeiteinschätzung: „meist innerhalb von 1–2 Wochen“ (Text in `config`, kein Versprechen).
- Projektauswahl bei mehreren Websites.
- Pflicht-Checkbox (Widerruf, analog Abo-Bestellung): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf
der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald
der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Einwilligung wird mit Zeitstempel und
Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten).
- Knopf „**Zahlungspflichtig bestellen · 30 €**“ ⇒ `POST /api/funktionen/news/checkout`.
**Zahlung**: Stripe Checkout `mode: 'payment'`, einmaliger Preis über `ensurePrice('news_setup')`
(Katalog in `config`/`services/products.js`, 30,00 € brutto), `invoice_creation.enabled = true`,
Zahlarten wie beim Abo (Karte, SEPA). Metadaten `{ customerId, projectId, featureKey: 'news' }`.
**Stripe-Webhook** `checkout.session.completed` (bzw. `checkout.session.async_payment_succeeded` bei SEPA):
1. Feature-Dokument auf `setup`, `stripeInvoiceId` merken.
2. WOMS-Ticket anlegen (neue Funktion `createFeatureTicket` in `ticketAdmin.js`, gleiche Felder wie
`ensureProjectTicket`): `topic` „News-Pinnwand einbauen – <Projekt>“, `type` 'Webpage',
`status` 'Open', `details` mit Projekt, Website-Domain, siteKey und Link zur Spec/Einbauanleitung.
`ticketId` am Feature-Dokument.
3. Mails: Kunde „Deine Bestellung: News-Pinnwand“ (neue Vorlage im Stil von K01), Team-Hinweis (T-Vorlage).
Idempotent über `stripeSessionId` (Webhook kann mehrfach kommen).
**Aktivieren**: Hintergrund-Job alle 5 Minuten (neben den vorhandenen `setInterval`-Jobs in
`server/index.js`) plus sofortige Prüfung beim Öffnen von Funktionen: Features in `setup`, deren Ticket
`status === 'Closed'` hat ⇒ `active`, `activatedAt`, Mail an Kunden „Deine Pinnwand ist bereit“.
Wird ein Ticket wieder geöffnet, bleibt die Funktion aktiv (kein Zurückschalten).
**Rechnung**: kommt per Stripe-Mail; zusätzlich Link „Rechnung ansehen“ in der News-Ansicht
(`hosted_invoice_url`). Der Tab „Rechnungen“ (Invoice Ninja) bleibt unverändert.
**Admin** (`admin.html`, Kundenansicht): je Projekt Zeile „News“ mit Zustand und Knöpfen
„Aktivieren (ohne Zahlung)“ / „Abschalten“. Protokolliert wer/wann.
## B2. Editor im Portal (`#view-news`, Zustand `active`)
Aufbau wie der Entwurf („Meine Pinnwand“):
- Schritt 1: Art – Text · Foto · Foto + Text.
- Schritt 2: Foto (Datei; am Handy zusätzlich „Foto aufnehmen“ per `capture`), Überschrift (≤ 60 Zeichen,
bei Foto „Bildunterschrift“), Text (≤ 400 Zeichen, Zähler), Zettelfarbe (Sonnengelb, Himmelblau,
Koralle, Weiß), **Anzeigedauer** (E6; „bis zu einem Datum“ öffnet Datumsfeld, frühestens morgen,
höchstens 1 Jahr).
- Live-Vorschau „so sehen es alle“ (Desktop rechts, sticky; Handy aufklappbar).
- Knopf „Aufhängen“; beim Ändern „Speichern“.
- Liste „Hängt gerade“ (Ändern, Abnehmen) und „Abgelaufen“ (Wieder aufhängen = neue Dauer wählen, Löschen
mit Rückfrage in der Seite, kein `confirm()`).
- Oben „Auf meiner Website ansehen“ (Link auf die Live-Domain des Projekts).
- Zustand `setup`: Statuskarte „Wir bauen deine Pinnwand ein“ mit Ticketnummer, Bestelldatum,
Zeiteinschätzung, Rechnungslink, „Frage dazu? Chat öffnen“.
**API** (alle `requireSession`, Kunde muss Eigentümer des Projekts sein, Feature `active`):
- `GET /api/funktionen` – Zustände aller Funktionen je Projekt.
- `POST /api/funktionen/news/checkout` – `{ projectId, consent }` ⇒ `{ url }`.
- `GET /api/news?projectId=` – eigene Beiträge (hängend + abgelaufen).
- `POST /api/news` – neu; `PATCH /api/news/:id` – ändern/abnehmen/wieder aufhängen; `DELETE /api/news/:id`.
- `POST /api/news/image` – Upload (multipart), Antwort `{ imageId }`.
Rate-Limit: höchstens 30 Schreibvorgänge und 20 Uploads pro Kunde und Stunde.
## B3. Speicherung
**Collection `newsPosts`** (Schema-Skript wie `portal-setup.mjs`):
`customerId`, `projectId`, `type` (`note`|`photo`|`combo`), `title` (60), `text` (400), `imageId`,
`paper` (`sun`|`sky`|`coral`|`white`), `visibleFrom` (ISO), `visibleUntil` (ISO oder leer = bis abgenommen),
`takenDownAt` (ISO oder leer), `createdAt`, `updatedAt`. Index auf (`projectId`, `visibleFrom`).
„Hängt“ ⇔ `takenDownAt` leer ∧ `visibleFrom ≤ jetzt` ∧ (`visibleUntil` leer ∨ `visibleUntil > jetzt`).
**Bilder**: `upload-data/news/<projectId>/<uuid>.webp` (Muster wie `chatAttachments.js`).
Annahme: JPEG, PNG, WebP, HEIC ≤ 10 MB. Verarbeitung mit `sharp` (neue Abhängigkeit): EXIF-Rotation
anwenden, **alle Metadaten entfernen** (GPS!), längste Kante ≤ 1600 px, WebP q≈80; zusätzlich eine
Vorschau ≤ 480 px. Nicht verwendete Uploads werden nach 24 h gelöscht (wie Chat). Beim Löschen eines
Beitrags wird das Bild mitgelöscht.
**Aufbewahrung**: Beiträge bleiben, bis der Kunde sie löscht. Bei `disabled` werden sie nicht mehr
ausgeliefert; die Löschung folgt der bestehenden Kundendaten-Frist in `services/retention.js`
(Erweiterung um `newsPosts` + Bilder).
## B4. Anzeige auf der Kundenwebsite
**siteKey**: zufälliger, nicht erratbarer Schlüssel je Feature-Dokument (z. B. 16 Zeichen base32), damit
keine Projekt- oder Kunden-IDs öffentlich werden.
**Öffentliche Schnittstelle** (ohne Login, nur GET):
- `GET /api/public/news/:siteKey?page=1` ⇒ `{ posts: [{ id, type, title, text, paper, date, image: { src, w, h } }], hasMore }`,
6 pro Seite, neueste zuerst. Nur hängende Beiträge; Feature nicht `active` ⇒ `{ posts: [], hasMore: false }`.
Keine internen Felder. `Cache-Control: public, max-age=60`, CORS `*` (nur GET), ETag.
- `GET /api/public/news/:siteKey/img/:imageId(.webp|-s.webp)` ⇒ Bild, nur wenn der Beitrag hängt;
`Cache-Control: public, max-age=86400`, `X-Content-Type-Options: nosniff`.
- Rate-Limit pro IP großzügig (z. B. 120/min), damit nichts missbraucht wird.
**Einbau-Skript** `https://project.webklar.com/embed/news.js` (statisch aus `public/embed/`):
- Sucht `<div data-webklar-news="<siteKey>">`, lädt die Beiträge, rendert Zettel (Text-Zettel, Polaroid,
Polaroid + Zettel) wie im Entwurf inkl. „Neu“-Schild am neuesten, Leerzustand „Hier hängt bald was Neues.“,
„Ältere Neuigkeiten ansehen“ (lädt Seite 2 nach), Handy: Wisch-Reihe.
- Optik über CSS-Variablen, die die Seite setzt (`--wkn-board`, `--wkn-frame`, `--wkn-font-display`,
`--wkn-font-body`, Zettelfarben); Standard = Kork + Holzrahmen.
- Kein Cookie, kein Storage, keine weiteren Drittanfragen. Text wird als Text eingesetzt (kein innerHTML
mit Kundentext). Ohne JS oder bei Fehler bleibt der statische Leerzustand im HTML stehen.
- Der Einbau (die 30 €) = Sektion im Stil der Kundenseite + `div` + Skript + Datenschutz-Absatz.
**Datenschutz**:
- Kundenwebsite: Absatz „Neuigkeiten“ (Abruf von project.webklar.com; bei bei uns gehosteten Seiten ist
WEBklar ohnehin Hoster/Auftragsverarbeiter; keine Cookies).
- Portal-Datenschutzerklärung: Abschnitt zu News-Beiträgen und Fotos (Zweck, Speicherort, Löschung,
Entfernen der Metadaten, öffentliche Sichtbarkeit auf der eigenen Website).
- Beide Texte werden als Entwurf formuliert und dem Nutzer zur Freigabe vorgelegt.
**Pilot**: Emma's Schatzkiste – Admin aktiviert News ohne Zahlung, Einbau in Kork-Optik auf der Startseite
(Position nach dem Hero, Nav-Punkt „Neuigkeiten“).
## Fehlerfälle
- Checkout abgebrochen/verfallen ⇒ `off`, Kunde kann neu buchen.
- Webhook doppelt ⇒ idempotent über `stripeSessionId`.
- SEPA-Zahlung scheitert nachträglich ⇒ `off`, Ticket bekommt Notiz, Team-Mail.
- Ticket gelöscht statt geschlossen ⇒ Feature bleibt `setup`, Team-Hinweis im Admin („Ticket fehlt“).
- Upload zu groß / falscher Typ ⇒ Meldung am Feld („Das Foto ist größer als 10 MB. Bitte ein kleineres wählen.“).
- Appwrite nicht erreichbar ⇒ Portal zeigt Fehler am Editor; öffentliche Schnittstelle liefert `503`,
das Skript lässt den Leerzustand stehen.
## Tests
- `npm test` (node --test), mit den vorhandenen Test-Doubles (`test/support/` appwriteMock, fakeStripe):
Zustandsübergänge inkl. Idempotenz, Ticket geschlossen ⇒ aktiv, Ablauf-Logik „hängt“ an Grenzen
(Zeitzonen Europe/Berlin), öffentliche Schnittstelle gibt nur Freigegebenes und keine internen Felder aus,
Bildverarbeitung entfernt EXIF/GPS, Rechte (fremdes Projekt ⇒ 403), Rate-Limits.
- Playwright auf Fixture-Server: Projekte mit Fortschritt-Sektion, `#fortschritt`-Umleitung, Funktionen-
Kacheln in allen drei Zuständen, Buchungsseite, Editor (PC + Handy), `news.js` auf einer Testseite
(leer, 1 Beitrag, 8 Beiträge mit „Ältere“), ohne JS.
- Stripe-Checkout gegen fakeStripe, wie bei den Abo-Tests.
## Nicht Teil dieser Spec
- Teilprojekt C „Analyse“ (Cookie-Banner, Einwilligung, Auswertung).
- Terminplaner.
- Mehrsprachigkeit, Kommentare/Likes unter Beiträgen, geplante Veröffentlichung in der Zukunft.
## Offene Punkte zur Freigabe
1. Texte: Buchungsseite, Widerrufs-Checkbox, Kunden-/Team-Mails, Datenschutz-Absätze (Portal + Website) –
Entwurf kommt mit dem Plan.
2. Braucht es einen Zusatz in den AGB/Vertragsunterlagen für die Einrichtungsleistung „News“, oder
reicht die Leistungsbeschreibung auf der Buchungsseite? (Empfehlung: kurze Leistungsbeschreibung als
eigenes Dokument „Funktionen – Leistungsbeschreibung“, auf der Buchungsseite verlinkt.)
+596
View File
@@ -16,6 +16,7 @@
"jose": "^6.0.11", "jose": "^6.0.11",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"sharp": "^0.34.5",
"stripe": "^19.3.0" "stripe": "^19.3.0"
}, },
"engines": { "engines": {
@@ -46,6 +47,529 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -210,6 +734,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -741,6 +1274,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -792,6 +1337,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -902,6 +1491,13 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+1
View File
@@ -23,6 +23,7 @@
"jose": "^6.0.11", "jose": "^6.0.11",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"sharp": "^0.34.5",
"stripe": "^19.3.0" "stripe": "^19.3.0"
} }
} }
+82
View File
@@ -185,6 +185,81 @@ function renderInvoices(data) {
</tbody></table>` </tbody></table>`
} }
const FEATURE_STATES = {
off: ['Aus', 'pill-muted'],
ordered: ['Bestellt', 'pill-amber'],
setup: ['Einrichtung', 'pill-warn'],
active: ['Aktiv', 'pill-ok'],
disabled: ['Abgeschaltet', 'pill-red'],
}
function featureRowHtml(p) {
const n = p.features.news
const [label, cls] = FEATURE_STATES[n.state] || [n.state, 'pill-muted']
const ticket = n.ticketMissing
? '<span class="pill pill-red">Ticket fehlt</span>'
: n.ticketWoid ? `#${escapeHtml(n.ticketWoid)}` : '<span class="muted">–</span>'
return `
<tr data-feature-project="${escapeHtml(p.projectId)}">
<td>${escapeHtml(p.projectName || '–')}</td>
<td><span class="pill ${cls}">${escapeHtml(label)}</span>${n.adminNote ? `<div class="muted" title="${escapeHtml(n.adminNote)}">${escapeHtml(n.adminNote.slice(0, 60))}${n.adminNote.length > 60 ? '…' : ''}</div>` : ''}</td>
<td>${ticket}</td>
<td class="feature-actions">
${n.state === 'active' ? '' : '<button type="button" class="mini-btn primary" data-feature-ask="activate">Aktivieren (ohne Zahlung)</button>'}
${n.state === 'off' ? '' : n.state === 'disabled' ? '' : '<button type="button" class="mini-btn" data-feature-ask="disable">Abschalten</button>'}
</td>
</tr>`
}
function renderFeatures(projects) {
if (!projects.length) return '<p class="muted">Keine Projekte für diesen Kunden.</p>'
return `<table class="admin-table"><thead><tr><th>Projekt</th><th>News</th><th>Ticket</th><th>Aktionen</th></tr></thead><tbody>
${projects.map(featureRowHtml).join('')}
</tbody></table><p class="feature-message muted" role="status"></p>`
}
function bindFeatureRows(box, customerId) {
const message = box.querySelector('.feature-message')
box.querySelectorAll('[data-feature-project]').forEach((row) => {
const cell = row.querySelector('.feature-actions')
const original = cell.innerHTML
const bindAsk = () => cell.querySelectorAll('[data-feature-ask]').forEach((btn) =>
btn.addEventListener('click', () => askFeature(btn.dataset.featureAsk)))
const askFeature = (action) => {
const text = action === 'activate'
? 'News ohne Zahlung aktivieren?'
: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.'
cell.innerHTML = `<span>${text}</span>
<input class="code-input" data-feature-note placeholder="Notiz (optional)" maxlength="120">
<button type="button" class="mini-btn primary" data-feature-yes>Ja</button>
<button type="button" class="mini-btn" data-feature-no>Abbrechen</button>`
cell.querySelector('[data-feature-no]').addEventListener('click', () => {
cell.innerHTML = original
bindAsk()
})
cell.querySelector('[data-feature-yes]').addEventListener('click', async () => {
const note = cell.querySelector('[data-feature-note]').value.trim()
cell.querySelectorAll('button').forEach((b) => { b.disabled = true })
try {
const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}/news`, {
method: 'POST',
body: JSON.stringify({ projectId: row.dataset.featureProject, action, note }),
})
row.outerHTML = featureRowHtml(projects[0])
bindFeatureRows(box, customerId)
box.querySelector('.feature-message').textContent = 'Gespeichert.'
} catch (err) {
cell.innerHTML = original
bindAsk()
message.textContent = err.message
message.className = 'feature-message error'
}
})
}
bindAsk()
})
}
async function toggleCustomerDetail(customerId, kind, cell) { async function toggleCustomerDetail(customerId, kind, cell) {
const existing = document.getElementById(`detail-${customerId}`) const existing = document.getElementById(`detail-${customerId}`)
if (existing) { if (existing) {
@@ -206,6 +281,10 @@ async function toggleCustomerDetail(customerId, kind, cell) {
if (kind === 'activity') { if (kind === 'activity') {
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`) const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
tr.innerHTML = `<td colspan="7"><h4>Tickets &amp; Arbeiten</h4>${renderActivity(tickets)}</td>` tr.innerHTML = `<td colspan="7"><h4>Tickets &amp; Arbeiten</h4>${renderActivity(tickets)}</td>`
} else if (kind === 'features') {
const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}`)
tr.innerHTML = `<td colspan="7"><h4>Funktionen</h4>${renderFeatures(projects)}</td>`
bindFeatureRows(tr, customerId)
} else { } else {
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`) const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>` tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>`
@@ -246,6 +325,7 @@ async function loadCustomers(search = '') {
title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse</a>` : ''} title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse</a>` : ''}
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button> <button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button> <button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
<button class="mini-btn" data-features="${escapeHtml(c.id)}">Funktionen</button>
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button> <button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button> <button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
</div> </div>
@@ -258,6 +338,8 @@ async function loadCustomers(search = '') {
btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs))) btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
tbody.querySelectorAll('[data-activity]').forEach((btn) => tbody.querySelectorAll('[data-activity]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity'))) btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity')))
tbody.querySelectorAll('[data-features]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.features, 'features')))
tbody.querySelectorAll('[data-invoices]').forEach((btn) => tbody.querySelectorAll('[data-invoices]').forEach((btn) =>
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices'))) btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
tbody.querySelectorAll('[data-offer-for]').forEach((btn) => tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
+52 -16
View File
@@ -137,7 +137,7 @@ function projectState(project) {
return ['kb-state-off', 'Noch keine Website'] return ['kb-state-off', 'Noch keine Website']
} }
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' } const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand' }
function renderProjectCard(project, features) { function renderProjectCard(project, features) {
const li = document.createElement('li') const li = document.createElement('li')
@@ -146,7 +146,7 @@ function renderProjectCard(project, features) {
const url = project.previewUrl ? escapeAttr(project.previewUrl) : '' const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
const [stateClass, stateText] = projectState(project) const [stateClass, stateText] = projectState(project)
const extraChips = featuresForProject(features, project.id) const extraChips = featuresForProject(features, project.id)
.map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey) .map((f) => FEATURE_LABELS[f.featureKey]) // unbekannte Schluessel nie roh anzeigen
.filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label)) .filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label))
li.innerHTML = ` li.innerHTML = `
@@ -210,7 +210,7 @@ function renderProjectCard(project, features) {
/* --------------------------------------------------------- Projektdetails --- */ /* --------------------------------------------------------- Projektdetails --- */
// Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine // Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine
// Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch. // Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch.
// Dieselben Bausteine nutzt der Tab "Fortschritt" (loadCustomerActivity). // Dieselben Bausteine nutzt der Abschnitt "Was wir zuletzt erledigt haben" unter Projekte (loadCustomerActivity).
const PD_ICONS = { const PD_ICONS = {
update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>', update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>',
@@ -690,23 +690,34 @@ async function loadCustomerInvoices() {
} }
/* -------------------------------------------------------------- Aktivität --- */ /* -------------------------------------------------------------- Aktivität --- */
// Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten // Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste
// (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server) // und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten,
// "Alle anzeigen" zeigt den Rest.
const ACTIVITY_SHOWN = 3
async function loadCustomerActivity() { async function loadCustomerActivity() {
const body = document.getElementById('activity-body') const body = document.getElementById('activity-body')
if (!body) return if (!body) return
try { try {
const { tickets } = await api('/api/activity') const { tickets: all } = await api('/api/activity')
if (!tickets || !tickets.length) { if (!all || !all.length) {
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>' body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
return return
} }
body.innerHTML = tickets.map((t) => ` const tickets = [...all].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
const card = (t) => `
<article class="pd-order pd-order-card pd-tone-${escapeAttr(t.status.tone)}"> <article class="pd-order pd-order-card pd-tone-${escapeAttr(t.status.tone)}">
${renderOrder(t)} ${renderOrder(t)}
${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')} ${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')}
</article>`).join('') </article>`
const draw = (showAll) => {
body.innerHTML = tickets.slice(0, showAll ? tickets.length : ACTIVITY_SHOWN).map(card).join('') +
(tickets.length > ACTIVITY_SHOWN
? `<button type="button" class="kb-btn-outline kb-activity-all" data-activity-all>${showAll ? 'Weniger anzeigen' : `Alle anzeigen (${tickets.length})`}</button>`
: '')
initPdToggles(body) initPdToggles(body)
body.querySelector('[data-activity-all]')?.addEventListener('click', () => draw(!showAll))
}
draw(false)
} catch (err) { } catch (err) {
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>` body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
} }
@@ -1948,14 +1959,32 @@ function initPasswordView() {
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #vertraege, #gutschrift). // Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #vertraege, #gutschrift).
// Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES). // Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES).
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht. // "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). // "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' } // "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' } const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat } const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
}
const loadedViews = new Set() const loadedViews = new Set()
function currentHash() {
return window.location.hash.replace(/^#/, '').split('?')[0]
}
// Direktlink #fortschritt: nach dem Laden der Projekte zum Abschnitt scrollen
let activityScrollPending = false
function scrollToActivity() {
if (!activityScrollPending) return
document.getElementById('pj-activity')?.scrollIntoView({ block: 'start' })
}
function viewFromHash() { function viewFromHash() {
const hash = window.location.hash.replace(/^#/, '') const hash = currentHash()
if (hash === 'fortschritt') return 'projects'
if (HASH_ALIASES[hash]) return HASH_ALIASES[hash] if (HASH_ALIASES[hash]) return HASH_ALIASES[hash]
return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || '' return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || ''
} }
@@ -1969,6 +1998,10 @@ function showView(name, { updateHash = true } = {}) {
loadedViews.add(name) loadedViews.add(name)
viewLoaders[name]() viewLoaders[name]()
} }
viewRefreshers[name]?.().catch((err) => {
const box = document.getElementById(`${name}-body`)
if (box) box.innerHTML = `<p class="muted">${escapeHtml(err.message || 'Konnte nicht geladen werden.')}</p>`
})
if (name === 'chat') { if (name === 'chat') {
api('/api/chat/read', { method: 'POST' }).catch(() => {}) api('/api/chat/read', { method: 'POST' }).catch(() => {})
updateChatBadge(0) updateChatBadge(0)
@@ -2073,10 +2106,12 @@ async function initDashboardPage() {
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`) if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
loadContracts({ afterCheckout: aboParam === 'success' }) loadContracts({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices() loadCustomerInvoices()
activityScrollPending = currentHash() === 'fortschritt'
loadCustomerActivity().then(scrollToActivity)
const initialView = viewFromHash() const initialView = viewFromHash()
// Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben // Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[window.location.hash.replace(/^#/, '')]) }) if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[currentHash()]) })
const [{ projects }, { features }] = await Promise.all([ const [{ projects }, { features }] = await Promise.all([
api('/api/projects'), api('/api/projects'),
@@ -2098,6 +2133,7 @@ async function initDashboardPage() {
} else { } else {
empty.classList.remove('hidden') empty.classList.remove('hidden')
} }
scrollToActivity()
// Chat-Badge global pollen (auch wenn der Chat nicht offen ist) // Chat-Badge global pollen (auch wenn der Chat nicht offen ist)
pollChatBadge() pollChatBadge()
@@ -2121,8 +2157,8 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
const coarsePointer = window.matchMedia('(pointer: coarse)') const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil). // Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde. // "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' } const APP_PARENT = { news: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' } const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null } const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])' const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
+24 -8
View File
@@ -21,6 +21,7 @@
<link rel="stylesheet" href="/login.css"> <link rel="stylesheet" href="/login.css">
<link rel="stylesheet" href="/kundenbereich.css"> <link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css"> <link rel="stylesheet" href="/chat-composer.css">
<link rel="stylesheet" href="/funktionen.css">
</head> </head>
<body class="portal-page dashboard-page kb-page"> <body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true"> <div class="dot-background" aria-hidden="true">
@@ -99,9 +100,9 @@
<button type="button" class="kb-nav-item active" data-view="projects"> <button type="button" class="kb-nav-item active" data-view="projects">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 9h18M6.5 6.5h.01M9.5 6.5h.01"/></svg> <svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 9h18M6.5 6.5h.01M9.5 6.5h.01"/></svg>
<span class="kb-tab-label">Projekte</span> <span class="kb-count" id="projects-count"></span></button> <span class="kb-tab-label">Projekte</span> <span class="kb-count" id="projects-count"></span></button>
<button type="button" class="kb-nav-item" data-view="activity"> <button type="button" class="kb-nav-item" data-view="funktionen">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l3-7 4 14 3-7h4"/></svg> <svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7.5" height="7.5" rx="2"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="2"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="2"/><path d="M17.25 13.5v7.5M13.5 17.25H21"/></svg>
<span class="kb-tab-label">Fortschritt</span></button> <span class="kb-tab-label">Funktionen</span></button>
<button type="button" class="kb-nav-item" data-view="invoices"> <button type="button" class="kb-nav-item" data-view="invoices">
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg> <svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2z"/><path d="M9 8h6M9 12h6M9 16h3"/></svg>
<span class="kb-tab-label">Rechnungen</span> <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button> <span class="kb-tab-label">Rechnungen</span> <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button>
@@ -139,6 +140,12 @@
<p id="loading" class="kb-empty">Projekte werden geladen…</p> <p id="loading" class="kb-empty">Projekte werden geladen…</p>
<ul id="projects" class="kb-projects hidden"></ul> <ul id="projects" class="kb-projects hidden"></ul>
<div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div> <div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div>
<!-- Fortschritt: die neuesten Aufträge (app.js, loadCustomerActivity); Direktlink #fortschritt -->
<section class="kb-activity" id="pj-activity" aria-labelledby="pj-activity-title">
<h2 id="pj-activity-title">Was wir zuletzt erledigt haben</h2>
<div id="activity-body"><p class="kb-empty">Lade…</p></div>
</section>
</section> </section>
<!-- App-Ansicht: Projektdetails als eigene Seite mit Zurueck zu "Projekte" (app.js, openProjectPage); <!-- App-Ansicht: Projektdetails als eigene Seite mit Zurueck zu "Projekte" (app.js, openProjectPage);
@@ -151,13 +158,22 @@
<div class="kb-details pd-page"></div> <div class="kb-details pd-page"></div>
</section> </section>
<!-- Fortschritt --> <!-- Funktionen: Kacheln (funktionen.js, initFunktionen); News ist auf dem Handy eine Unterseite davon -->
<section class="kb-view" id="view-activity"> <section class="kb-view" id="view-funktionen">
<header class="kb-head"> <header class="kb-head">
<h1>Fortschritt</h1> <h1>Funktionen</h1>
<p>Was wir an deinen Projekten erledigt haben.</p> <p>Zusätzliche Bausteine für deine Website – ein Klick, wir richten sie ein.</p>
</header> </header>
<div class="kb-panel" id="activity-body"><p class="kb-empty">Lade…</p></div> <div id="funktionen-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- News-Pinnwand: Buchung, Einrichtungsstatus oder Editor (funktionen.js, initNews) -->
<section class="kb-view" id="view-news">
<header class="kb-head">
<h1>News-Pinnwand</h1>
<p>Kurze Neuigkeiten, die auf deiner Website hängen.</p>
</header>
<div id="news-body"><p class="kb-empty">Lade…</p></div>
</section> </section>
<!-- Rechnungen --> <!-- Rechnungen -->
+7
View File
@@ -83,6 +83,13 @@
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p> <p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p> <p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
<h2 id="news">6a. News-Beiträge und Fotos</h2>
<p>Mit der Funktion „News-Pinnwand“ können Sie im Kundenportal kurze Beiträge mit Titel, Text und Foto schreiben, die auf Ihrer eigenen Website erscheinen. Dafür verarbeiten wir den Inhalt Ihrer Beiträge, hochgeladene Fotos, Zettelfarbe und Anzeigedauer sowie die Zeitpunkte von Anlage, Änderung und Abnahme. Beiträge und Fotos speichern wir auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).</p>
<p>Jedes Foto wird beim Hochladen neu kodiert und verkleinert. Dabei entfernen wir alle Metadaten, auch Angaben zum Aufnahmeort (GPS) und zur Kamera. Nicht verwendete Uploads löschen wir nach etwa 24 Stunden.</p>
<p>Veröffentlichte Beiträge sind auf Ihrer Website öffentlich sichtbar, bis die von Ihnen gewählte Anzeigedauer endet oder Sie den Beitrag abnehmen. Die Besucher Ihrer Website sehen dabei nur den Inhalt der Beiträge, nicht Ihre Kundendaten. Sie können Beiträge und Fotos jederzeit im Kundenportal löschen. Was beim Aufruf der Pinnwand durch einen Besucher übertragen wird, steht in der Datenschutzerklärung Ihrer Website.</p>
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag). Auf Ihren Wunsch oder wenn die Zusammenarbeit endet und wir die Funktion abschalten, ist die Pinnwand auf Ihrer Website nicht mehr erreichbar. Beiträge und Fotos löschen wir, wenn Sie sie selbst löschen, andernfalls nach den Fristen in Abschnitt 7.</p>
<h2>7. Abos und Zahlung über Stripe</h2> <h2>7. Abos und Zahlung über Stripe</h2>
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p> <p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p> <p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>News-Pinnwand Testseite</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#fff;color:#232A33}
header,footer{padding:16px 24px;background:#f1f3f6}
main{max-width:1100px;margin:0 auto;padding:32px 16px}
.static-empty{text-align:center;padding:48px 16px;border:2px dashed #C08A52;border-radius:16px}
</style>
</head>
<body>
<header>Testseite der Kundenwebsite (intern, nicht verlinkt)</header>
<main>
<h2>Neues aus dem Laden</h2>
<!-- data-api weglassen = Herkunft des Skripts; data-signature="Opa Martin" optional -->
<div data-webklar-news="abcdefghijklmnop">
<div class="static-empty" id="static-empty">
<h3>Hier hängt bald was Neues.</h3>
<p>Neuigkeiten findest du bald an dieser Pinnwand.</p>
</div>
</div>
</main>
<footer>Fußbereich</footer>
<script src="/embed/news.js" defer></script>
</body>
</html>
+251
View File
@@ -0,0 +1,251 @@
/*! WEBklar Neuigkeiten-Pinnwand: Einbau-Skript fuer Kundenwebsites.
* <div data-webklar-news="SITEKEY" data-api="https://project.webklar.com">statischer Leerzustand</div>
* Keine Abhaengigkeiten, keine Cookies, kein Storage. Kundentext nur ueber textContent. */
(function () {
'use strict'
var SELF = document.currentScript
var STYLE_ID = 'wkn-style'
var MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
var ROT = [-1.6, 1.2, -0.8, 1.8, -1.3, 0.9]
var PAPERS = { sun: 1, sky: 1, coral: 1, white: 1 }
var CSS = [
'.wkn-root{container:wkn-root/inline-size;display:block}',
':where(.wkn-root){--wkn-board:#C08A52;--wkn-frame:#A8733F;--wkn-font-display:"Fredoka",system-ui,sans-serif;--wkn-font-body:"Nunito",system-ui,sans-serif;--wkn-paper-sun:#FFF6D6;--wkn-paper-sky:#DDEEFB;--wkn-paper-coral:#FFE1DA;--wkn-paper-white:#fff;--wkn-ink:#232A33;--wkn-ink-soft:#5B6673;font-family:var(--wkn-font-body)}',
'.wkn-root *,.wkn-root *::before,.wkn-root *::after{box-sizing:border-box}',
'.wkn-board{position:relative;isolation:isolate;overflow:hidden;border-radius:28px;padding:40px 32px 48px;background-color:var(--wkn-board);',
'background-image:url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27260%27 height=%27260%27%3E%3Cfilter id=%27a%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%271.1%27 numOctaves=%272%27 seed=%273%27/%3E%3CfeColorMatrix values=%270 0 0 0 .29 0 0 0 0 .16 0 0 0 0 .06 0 0 0 -2.4 1.25%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23a)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27220%27 height=%27220%27%3E%3Cfilter id=%27b%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.85%27 numOctaves=%272%27 seed=%2711%27/%3E%3CfeColorMatrix values=%270 0 0 0 .95 0 0 0 0 .8 0 0 0 0 .58 0 0 0 2.6 -1.55%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23b)%27/%3E%3C/svg%3E"),url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27600%27 height=%27600%27%3E%3Cfilter id=%27c%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%27.012%27 numOctaves=%273%27 seed=%277%27/%3E%3CfeColorMatrix values=%270 0 0 0 .45 0 0 0 0 .27 0 0 0 0 .12 0 0 0 1.4 -.45%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23c)%27/%3E%3C/svg%3E");',
'border:12px solid var(--wkn-frame);box-shadow:inset 0 0 0 1px rgba(80,45,15,.35),inset 0 6px 14px rgba(60,32,10,.35),0 0 0 1px rgba(90,55,25,.5),0 12px 30px rgba(35,52,74,.18)}',
'.wkn-grid{display:grid;gap:32px 24px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}',
'.wkn-grid>:first-child{grid-column:span 2}',
'.wkn-pin{position:relative;transform:rotate(var(--wkn-r,0deg));transition:transform .25s ease;color:var(--wkn-ink)}',
'.wkn-pin:hover{transform:rotate(0deg) translateY(-4px)}',
'.wkn-pin::before{content:"";position:absolute;top:-12px;left:50%;width:84px;height:24px;z-index:2;transform:translateX(-50%) rotate(calc(var(--wkn-r,0deg) * -1.5 - 2deg));background:rgba(255,255,255,.62);box-shadow:0 1px 2px rgba(35,52,74,.12);border-left:2px dotted rgba(35,52,74,.06);border-right:2px dotted rgba(35,52,74,.06)}',
'.wkn-date{white-space:nowrap;font:700 12px/1 var(--wkn-font-body);letter-spacing:.08em;text-transform:uppercase;color:var(--wkn-ink-soft)}',
'.wkn-sig{font:600 15px/1 var(--wkn-font-display);color:var(--wkn-ink-soft);margin-top:14px}',
'.wkn-new{position:absolute;top:-14px;right:-10px;z-index:3;transform:rotate(8deg);background:#E4573D;color:#fff;font:700 12px/1 var(--wkn-font-body);letter-spacing:.04em;padding:7px 12px;border-radius:999px;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
'.wkn-note{padding:28px 24px 22px;border-radius:6px 6px 18px 6px;box-shadow:0 8px 18px rgba(35,52,74,.14);background:var(--wkn-paper,#fff)}',
'.wkn-note h3{font:600 20px/1.25 var(--wkn-font-display);color:var(--wkn-ink);margin:8px 0}',
'.wkn-note p{font:400 16px/1.5 var(--wkn-font-body);color:var(--wkn-ink);margin:0;white-space:pre-line;overflow-wrap:break-word;hyphens:auto}',
'.wkn-lead .wkn-note h3,.wkn-lead.wkn-note h3{font-size:28px}',
'.wkn-paper-sun{--wkn-paper:var(--wkn-paper-sun)}.wkn-paper-sky{--wkn-paper:var(--wkn-paper-sky)}.wkn-paper-coral{--wkn-paper:var(--wkn-paper-coral)}.wkn-paper-white{--wkn-paper:var(--wkn-paper-white)}',
'.wkn-polaroid{margin:0;background:#fff;padding:12px 12px 16px;border-radius:4px;box-shadow:0 10px 22px rgba(35,52,74,.16)}',
'.wkn-polaroid img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;border-radius:2px;background:#eef0f3}',
'.wkn-polaroid figcaption{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-top:12px}',
'.wkn-polaroid figcaption b{font:600 17px/1.25 var(--wkn-font-display);color:var(--wkn-ink);overflow-wrap:break-word;hyphens:auto}',
'.wkn-combo{container:wkn-card/inline-size}',
'.wkn-combo-in{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);align-items:center}',
'.wkn-combo .wkn-polaroid{transform:rotate(-2deg)}',
'.wkn-combo .wkn-note{margin-left:-28px;transform:rotate(2.5deg);z-index:1}',
'.wkn-combo::before{left:28%}',
'.wkn-more[hidden]{display:none}',
'.wkn-more{display:flex;justify-content:center;margin-top:40px}',
'.wkn-btn{font:700 16px/1 var(--wkn-font-body);color:var(--wkn-ink);background:#fff;border:0;border-radius:999px;padding:14px 24px;cursor:pointer;box-shadow:0 3px 8px rgba(35,52,74,.25)}',
'.wkn-btn:hover{transform:translateY(-1px)}.wkn-btn:focus-visible{outline:3px solid var(--wkn-ink);outline-offset:2px}.wkn-btn[disabled]{opacity:.6;cursor:default}',
'.wkn-swipehint{display:none;width:max-content;margin:8px auto 0;justify-content:center;align-items:center;gap:6px;font:400 13px/1.2 var(--wkn-font-body);color:var(--wkn-ink);background:rgba(255,255,255,.85);padding:5px 12px;border-radius:999px}',
'.wkn-dots{display:flex;gap:6px;margin-right:8px}',
'.wkn-dots i{width:7px;height:7px;border-radius:50%;background:rgba(35,42,51,.2);transition:width .2s}',
'.wkn-dots i.wkn-on{background:var(--wkn-ink);width:18px;border-radius:999px}',
'@container wkn-root (max-width:700px){',
'.wkn-board{padding:32px 0;border-radius:20px}',
'.wkn-grid{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;gap:18px;padding:28px 20px 12px;scrollbar-width:none}',
'.wkn-grid::-webkit-scrollbar{display:none}',
'.wkn-grid>*{flex:0 0 76%;scroll-snap-align:center}',
'.wkn-grid>:first-child{grid-column:auto}',
'.wkn-lead .wkn-note h3,.wkn-lead.wkn-note h3{font-size:20px}',
'.wkn-more{margin-top:24px;padding-inline:20px}.wkn-more .wkn-btn{width:100%}',
'.wkn-swipehint{display:flex}',
'}',
'@container wkn-card (max-width:340px){.wkn-combo-in{grid-template-columns:minmax(0,1fr)}.wkn-combo .wkn-note{margin:-26px 12px 0 26px}}',
'@media (prefers-reduced-motion:reduce){.wkn-pin,.wkn-btn,.wkn-dots i{transition:none}}'
].join('\n')
function injectStyle () {
if (document.getElementById(STYLE_ID)) return
var st = document.createElement('style')
st.id = STYLE_ID
st.textContent = CSS
document.head.appendChild(st)
}
function el (tag, cls, text) {
var e = document.createElement(tag)
if (cls) e.className = cls
if (text != null) e.textContent = text
return e
}
// 'YYYY-MM-DD' (bereits Berliner Kalendertag) -> '29. Sep'
function fmtDate (d) {
var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(d || ''))
if (!m) return ''
return parseInt(m[3], 10) + '. ' + MONTHS[parseInt(m[2], 10) - 1]
}
function absUrl (api, p) {
return typeof p === 'string' && p.charAt(0) === '/' ? api + p : ''
}
function buildNote (p, extraCls) {
var paper = PAPERS[p.paper] ? p.paper : 'sun'
var n = el('div', 'wkn-note wkn-paper-' + paper + (extraCls || ''))
n.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
if (p.title) n.appendChild(el('h3', '', p.title))
n.appendChild(el('p', '', p.text || ''))
if (p.signature) n.appendChild(el('div', 'wkn-sig', p.signature))
return n
}
function buildPhoto (p, api, withCaption) {
var fig = el('figure', 'wkn-polaroid')
if (p.image) {
var img = document.createElement('img')
var iw = Number.isFinite(+p.image.w) && +p.image.w > 0 ? Math.round(+p.image.w) : 1600
var ih = Number.isFinite(+p.image.h) && +p.image.h > 0 ? Math.round(+p.image.h) : 1200
var small = absUrl(api, p.image.srcSmall)
var full = absUrl(api, p.image.src)
img.src = small || full
if (small && full) {
img.srcset = small + ' 480w, ' + full + ' ' + iw + 'w'
img.sizes = '(max-width:700px) 80vw, 480px'
}
img.width = iw
img.height = ih
img.alt = p.title || ''
img.loading = 'lazy'
img.decoding = 'async'
fig.appendChild(img)
}
if (withCaption) {
var cap = el('figcaption')
if (p.title) cap.appendChild(el('b', '', p.title))
cap.appendChild(el('span', 'wkn-date', fmtDate(p.date)))
fig.appendChild(cap)
}
return fig
}
function buildPin (p, index, isFirst, api, signature) {
var cls = 'wkn-pin' + (isFirst ? ' wkn-lead' : '')
var r = ROT[index % ROT.length]
var item
p = { id: p.id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: p.date, image: p.image, signature: signature }
if (p.type === 'photo' && p.image) {
item = buildPhoto(p, api, true)
item.className += ' ' + cls
item.style.setProperty('--wkn-r', r + 'deg')
} else if (p.type === 'combo' && p.image) {
item = el('article', cls + ' wkn-combo')
item.style.setProperty('--wkn-r', '0deg')
var inner = el('div', 'wkn-combo-in')
inner.appendChild(buildPhoto(p, api, false))
inner.appendChild(buildNote(p))
item.appendChild(inner)
} else {
item = el('article', cls)
item.style.setProperty('--wkn-r', r + 'deg')
var note = buildNote(p)
// Zettel-Klassen direkt auf die Pin-Huelle, damit Polster/Schatten nur einmal greifen
item.className += ' ' + note.className
while (note.firstChild) item.appendChild(note.firstChild)
}
if (isFirst) item.appendChild(el('span', 'wkn-new', 'Neu'))
return item
}
function init (root) {
var key = root.getAttribute('data-webklar-news')
if (!key || root.getAttribute('data-wkn-init')) return
root.setAttribute('data-wkn-init', '1')
var apiAttr = root.getAttribute('data-api')
var api = String(apiAttr || '').replace(/\/+$/, '')
if (!api && SELF && SELF.src) api = new URL(SELF.src).origin
var signature = root.getAttribute('data-signature') || ''
var state = { page: 1, shown: 0, ui: null, loading: false }
function fetchPage (page) {
return fetch(api + '/api/public/news/' + encodeURIComponent(key) + '?page=' + page, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json() })
.then(function (d) {
if (!d || !Array.isArray(d.posts)) throw new Error('format')
return d
})
}
function updateDots () {
var ui = state.ui
if (!ui) return
var kids = ui.grid.children
var center = ui.grid.scrollLeft + ui.grid.clientWidth / 2
var best = 0, bestDist = Infinity
for (var i = 0; i < kids.length; i++) {
var c = kids[i].offsetLeft + kids[i].offsetWidth / 2
if (Math.abs(c - center) < bestDist) { bestDist = Math.abs(c - center); best = i }
}
for (var j = 0; j < ui.dots.children.length; j++) ui.dots.children[j].className = j === best ? 'wkn-on' : ''
}
function append (posts) {
var ui = state.ui
posts.forEach(function (p) {
var idx = state.shown
ui.grid.appendChild(buildPin(p, idx, idx === 0, api, signature))
ui.dots.appendChild(document.createElement('i'))
state.shown++
})
updateDots()
}
function setMore (hasMore) {
var ui = state.ui
ui.more.hidden = !hasMore
ui.btn.disabled = false
}
function build (d) {
root.classList.add('wkn-root')
root.textContent = ''
var board = el('div', 'wkn-board')
var grid = el('div', 'wkn-grid')
var hint = el('div', 'wkn-swipehint')
var dots = el('span', 'wkn-dots')
hint.appendChild(dots)
hint.appendChild(document.createTextNode('wischen für mehr'))
var more = el('div', 'wkn-more')
var btn = el('button', 'wkn-btn', 'Ältere Neuigkeiten ansehen')
btn.type = 'button'
more.appendChild(btn)
board.appendChild(grid)
board.appendChild(hint)
board.appendChild(more)
root.appendChild(board)
state.ui = { grid: grid, dots: dots, more: more, btn: btn }
grid.addEventListener('scroll', updateDots, { passive: true })
btn.addEventListener('click', function () {
if (state.loading) return
state.loading = true
btn.disabled = true
fetchPage(state.page + 1).then(function (nd) {
state.page++
append(nd.posts)
setMore(!!nd.hasMore && nd.posts.length > 0)
}).catch(function () { btn.disabled = false }).then(function () { state.loading = false })
})
append(d.posts)
setMore(!!d.hasMore)
}
injectStyle()
fetchPage(1).then(function (d) {
if (d.posts.length > 0) build(d)
}).catch(function () { /* Fehler: statischer Leerzustand der Seite bleibt */ })
}
function run () {
var nodes = document.querySelectorAll('[data-webklar-news]')
for (var i = 0; i < nodes.length; i++) init(nodes[i])
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run)
else run()
})()
+139
View File
@@ -0,0 +1,139 @@
/* Tab "Funktionen" und News-Pinnwand (funktionen.js). Farben aus den Tokens von kundenbereich.css;
Aufbau des Editors nach dem freigegebenen Entwurf (Art, Felder, Zettelfarben, Live-Vorschau, "Hängt gerade"). */
/* Fortschritt unter Projekte (app.js, loadCustomerActivity) */
.kb-activity { margin-top: 32px; scroll-margin-top: calc(var(--kb-appbar-h, 0px) + 16px); }
.kb-activity h2 { margin: 0 0 14px; font-size: 20px; }
#activity-body { display: grid; gap: 16px; }
#activity-body > .muted { margin: 0; }
.kb-activity-all { justify-self: start; }
/* ------------------------------------------------------------- Kacheln --- */
.fn-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.fn-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; border-radius: 16px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.fn-tile h3 { margin: 4px 0 0; font-size: 18px; }
.fn-tile p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--kb-muted); }
.fn-tile .fn-count { font-size: 13px; color: var(--kb-lilac); }
.fn-tile-cta { margin-top: auto; }
.fn-tile-soon { opacity: 0.75; }
.fn-tile-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; }
.fn-ico { width: 34px; height: 34px; padding: 7px; box-sizing: border-box; border-radius: 10px; background: var(--kb-purple-soft); fill: none; stroke: var(--kb-lilac); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.fn-pill { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.fn-pill-off, .fn-pill-soon { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.fn-pill-setup { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.fn-pill-on { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
/* ------------------------------------------------- Buchung und Status --- */
#view-news .kb-panel { max-width: 640px; }
#view-news .nw .kb-panel { max-width: none; }
#view-news .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
.fn-project label { font-size: 13px; font-weight: 600; }
.fn-project select { flex: 1 1 220px; min-width: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; }
.fn-project select option { color: #111; }
.fn-booking p, .fn-status p { margin: 0 0 12px; line-height: 1.55; }
.fn-price { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.fn-price strong { font-size: 28px; font-variant-numeric: tabular-nums; }
.fn-price span, .fn-estimate { color: var(--kb-muted); font-size: 14px; }
.fn-consent { display: flex; gap: 10px; align-items: flex-start; margin: 16px 0; font-size: 13.5px; line-height: 1.5; cursor: pointer; }
.fn-consent input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); }
.fn-thanks { margin: 0 0 16px; padding: 12px 16px; max-width: 640px; border-radius: 12px; font-size: 14px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.fn-status h3 { margin: 10px 0 8px; }
.fn-facts { display: grid; gap: 10px; margin: 16px 0; }
.fn-facts > div { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.fn-facts dt { min-width: 150px; color: var(--kb-muted); font-size: 14px; }
.fn-facts dd { margin: 0; font-weight: 600; }
/* -------------------------------------------------------------- Editor --- */
.nw { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 20px; align-items: start; }
.nw-form { display: grid; gap: 16px; min-width: 0; }
.nw-side { display: grid; gap: 16px; min-width: 0; position: sticky; top: 84px; }
.nw-card h3 { margin: 0 0 16px; }
.nw-step { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--kb-lilac); }
.nw-types { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.nw-type {
display: grid; justify-items: center; gap: 8px; padding: 16px 6px 12px; border-radius: 14px; cursor: pointer;
border: 2px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-weight: 700; font-size: 14px; text-align: center;
}
.nw-type small { font-size: 12px; font-weight: 400; color: var(--kb-muted); }
.nw-type-ico { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: var(--kb-purple-soft); transform: rotate(-3deg); }
.nw-type-ico svg { width: 24px; height: 24px; fill: none; stroke: var(--kb-lilac); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.nw-type[aria-pressed="true"] { border-color: var(--kb-purple); background: var(--kb-purple-soft); }
.nw-field { display: grid; gap: 6px; margin-bottom: 18px; }
.nw-field > label { font-size: 13px; font-weight: 600; }
.nw-field input:not([type=file]), .nw-field select, .nw-field textarea {
width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; resize: vertical;
}
.nw-field select option { color: #111; }
.nw-field input:focus, .nw-field select:focus, .nw-field textarea:focus { outline: none; border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); }
.nw-hint { margin: 0; font-size: 12.5px; color: var(--kb-muted); }
.nw-hint-row { display: flex; justify-content: space-between; gap: 12px; }
.nw-err { margin: 0; font-size: 13px; color: #fca5a5; }
.nw-err:empty { display: none; }
.nw-drop { display: grid; justify-items: center; gap: 12px; padding: 18px 14px; text-align: center; border: 2px dashed var(--kb-purple-line); border-radius: 14px; background: var(--kb-purple-soft); }
.nw-drop-img { width: 100%; }
.nw-drop-img img { display: block; width: 100%; max-height: 220px; object-fit: cover; border-radius: 10px; }
.nw-drop-btns { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.nw-drop .nw-hint { margin: 0; }
.nw-only-phone { display: none; }
@media (pointer: coarse) { .nw-only-phone { display: inline-flex; } }
.nw-swatches { display: flex; gap: 10px; }
.nw-sw { width: 42px; height: 42px; padding: 0; border-radius: 12px; border: 2px solid rgba(255, 255, 255, 0.2); cursor: pointer; transform: rotate(-4deg); }
.nw-sw[aria-pressed="true"] { outline: 3px solid #fff; outline-offset: 2px; }
.nw-actions { margin-top: 6px; }
.nw-paper-sun { --paper: #fff6d6; }
.nw-paper-sky { --paper: #ddeffb; }
.nw-paper-coral { --paper: #ffe1d9; }
.nw-paper-white { --paper: #fff; }
.nw-sw, .nw-thumb.nw-paper-sun, .nw-thumb.nw-paper-sky, .nw-thumb.nw-paper-coral, .nw-thumb.nw-paper-white { background: var(--paper); }
/* Vorschau: Papierzettel und Polaroid, dunkler Text auf hellem Papier */
.nw-prev-label { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--kb-muted); }
.nw-prev-label i { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.2); }
#nw-preview { max-width: 380px; margin: 0 auto; color: #232a33; }
.nw-paper { padding: 24px 22px 20px; border-radius: 6px 6px 18px 6px; background: var(--paper, #fff); box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); transform: rotate(-1.2deg); }
.nw-paper h4 { margin: 8px 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
.nw-paper p { margin: 0; font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.nw-date { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7684; }
.nw-polaroid { margin: 0; padding: 10px 10px 14px; border-radius: 4px; background: #fff; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.4); transform: rotate(-2deg); }
.nw-polaroid img, .nw-ph { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 2px; background: #e6e9ee; }
.nw-ph { display: grid; place-items: center; color: #8a94a3; font-size: 14px; }
.nw-polaroid figcaption { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-top: 10px; }
.nw-polaroid figcaption b { font-size: 16px; overflow-wrap: anywhere; }
.nw-polaroid figcaption span { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #6b7684; }
.nw-combo { display: grid; gap: 0; }
.nw-combo .nw-paper { margin: -22px 10px 0 24px; transform: rotate(2deg); position: relative; }
.nw-prev-toggle { display: none; width: 100%; justify-content: center; }
/* Listen */
.nw-lists h3 { margin: 0; font-size: 16px; }
.nw-lists-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.nw-expired-title { margin-top: 22px !important; }
.nw-empty { margin: 8px 0 0; color: var(--kb-muted); font-size: 14px; }
.nw-rows { list-style: none; margin: 0; padding: 0; }
.nw-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; padding: 12px 0; border-top: 1px solid var(--kb-border); }
.nw-thumb { width: 46px; height: 46px; flex: none; border-radius: 8px; object-fit: cover; display: grid; place-items: center; color: #232a33; font-weight: 700; }
.nw-row-text { flex: 1 1 140px; min-width: 0; }
.nw-row-text b { display: block; font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nw-row-text small { color: var(--kb-muted); font-size: 12.5px; }
.nw-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.nw-row-actions button, .nw-row-actions select { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
.nw-row-actions select option { color: #111; }
.nw-row-actions button:hover { background: rgba(255, 255, 255, 0.07); }
.nw-row-actions .nw-del { color: #fca5a5; }
.nw-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.nw-row-err { flex: 1 0 100%; }
@media (max-width: 880px) {
.nw { grid-template-columns: minmax(0, 1fr); }
.nw-side { position: static; }
.nw-prev-toggle { display: flex; }
.nw-prev { display: none; }
.nw-prev.is-open { display: block; }
.nw-side { order: 2; }
.nw-types { gap: 8px; }
.nw-type small { display: none; }
.nw-actions .abo-btn { flex: 1 1 auto; }
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
}
+507
View File
@@ -0,0 +1,507 @@
/* Tab "Funktionen" und News-Pinnwand im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen).
initFunktionen(rootEl): Kacheln der Funktionen. initNews(rootEl, { projectId }): je nach Zustand der Funktion
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
const PRICE = '30 €'
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
const TITLE_MAX = 60
const TEXT_MAX = 400
const DURATIONS = [
['1w', '1 Woche'], ['2w', '2 Wochen'], ['1m', '1 Monat'], ['3m', '3 Monate'],
['open', 'Bis ich sie abnehme'], ['date', 'Bis zu einem Datum'],
]
const REHANG_DURATIONS = DURATIONS.filter(([key]) => key !== 'date')
const PAPERS = [['sun', 'Sonnengelb'], ['sky', 'Himmelblau'], ['coral', 'Koralle'], ['white', 'Weiß']]
const TYPES = [
['note', 'Text', 'ein Zettel', '<path d="M12 20h9"/><path d="M16.4 3.6a1 1 0 0 1 3 3L7.4 18.6a2 2 0 0 1-.9.5l-2.9.8a.5.5 0 0 1-.6-.6l.8-2.9a2 2 0 0 1 .5-.9z"/>'],
['photo', 'Foto', 'ein Polaroid', '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>'],
['combo', 'Foto + Text', 'beides', '<rect x="3" y="3" width="12" height="12" rx="2"/><path d="M9 21h10a2 2 0 0 0 2-2V9"/><path d="m3 11 3-3 4 4"/>'],
]
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '')
function fmtDay(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
// "2026-10-01" in der Zeitzone Berlin, plus Tage (fuer min/max des Datumsfelds)
function berlinIsoDay(offsetDays = 0) {
const d = new Date(Date.now() + offsetDays * 86400000)
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
}
async function request(path, { method = 'GET', body, raw } = {}) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {},
body: raw || (body ? JSON.stringify(body) : undefined),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`))
err.status = response.status
if (data.field) err.field = data.field
throw err
}
return data
}
/* ------------------------------------------------------------ Zustand --- */
// Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
function pickProject(projects) {
return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0]
}
/* ------------------------------------------------------------ Kacheln --- */
const ICONS = {
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
function newsTile(projects) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${PRICE}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="news">
<div class="fn-tile-head">${svg('news')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>News-Pinnwand</h3>
<p>Schreib kurze Neuigkeiten mit Text und Foto – sie hängen wie Zettel an einer Pinnwand auf deiner Website.</p>
${count}
${target
? `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="news" data-project="${esc(target.projectId)}">${cta}</button>`
: '<p class="fn-count">Sobald deine Website angelegt ist, kannst du die Pinnwand buchen.</p>'}
</article>`
}
const soonTile = (icon, title, text) => `
<article class="fn-tile fn-tile-soon">
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
<h3>${title}</h3>
<p>${text}</p>
</article>`
export async function initFunktionen(root) {
if (!root) return
const { projects = [] } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects)}
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
</div>`
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
}
/* ---------------------------------------------------------------- News --- */
export async function initNews(root, { projectId } = {}) {
if (!root) return
if (projectId) selection.projectId = projectId
const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
if (!projects.length) {
root.innerHTML = '<div class="kb-panel kb-empty">Noch keine Website-Projekte zugewiesen.</div>'
return
}
const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects)
selection.projectId = project.projectId
const feature = newsOf(project)
// Rueckkehr von Stripe (#news?bestellt=1): Flag nur einmal auswerten und aus der Adresse nehmen
const orderedFlag = /[?&]bestellt=1\b/.test(window.location.hash)
if (orderedFlag) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#news`)
const ordered = orderedFlag && isSetup(feature.state)
// Editor nicht neu aufbauen, solange Projekt und Zustand gleich sind: Entwurf und hochgeladenes Foto bleiben
const renderKey = `${project.projectId}:${feature.state}`
if (feature.state === 'active' && root.dataset.newsKey === renderKey && root.querySelector('.nw')) return
root.dataset.newsKey = feature.state === 'active' ? renderKey : ''
root.innerHTML = `
${projects.length > 1 ? `
<div class="fn-project kb-panel">
<label for="news-project">Website</label>
<select id="news-project">${projects.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
</div>` : ''}
<div id="news-stage"></div>`
root.querySelector('#news-project')?.addEventListener('change', (ev) => {
selection.projectId = ev.target.value
initNews(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#news-stage')
const thanks = ordered
? '<p class="fn-thanks" role="status">Danke! Sobald die Zahlung da ist, legen wir los.</p>'
: ''
if (feature.state === 'active') renderEditor(stage, project)
else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate)
else if (feature.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>News-Pinnwand ist abgeschaltet</h3><p>Für diese Website ist die Pinnwand im Moment nicht aktiv. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
}
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
stage.innerHTML = `
${thanks}
<div class="kb-panel fn-booking">
<h3>News-Pinnwand einrichten lassen</h3>
<p>Wir bauen die Pinnwand in deine Website <strong>${esc(project.projectName)}</strong> ein. Danach schreibst du deine Neuigkeiten selbst hier im Kundenbereich – mit Text, Foto und Anzeigedauer.</p>
<ul class="kb-checks">
<li>Einrichtung und Einbau durch uns</li>
<li>Beiträge jederzeit selbst schreiben, ändern und abnehmen</li>
<li>Nutzung danach kostenlos, kein Abo</li>
</ul>
<p class="fn-price"><strong>${PRICE}</strong> <span>einmalig, inklusive Mehrwertsteuer</span></p>
${setupEstimate ? `<p class="fn-estimate">Einrichtung: ${esc(setupEstimate)}</p>` : ''}
<p class="fn-desc-link"><a href="${LEISTUNGSBESCHREIBUNG}" target="_blank" rel="noopener">Leistungsbeschreibung</a></p>
<label class="fn-consent"><input type="checkbox" id="news-consent"><span id="news-consent-text"></span></label>
<p class="error hidden" id="news-order-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>Zahlungspflichtig bestellen · ${PRICE}</button></div>
</div>`
stage.querySelector('#news-consent-text').textContent = consentText
const box = stage.querySelector('#news-consent')
const button = stage.querySelector('#news-order')
const error = stage.querySelector('#news-order-error')
box.addEventListener('change', () => { button.disabled = !box.checked })
button.addEventListener('click', async () => {
if (!box.checked) return
button.disabled = true
error.classList.add('hidden')
try {
const { url } = await request('/api/funktionen/news/checkout', { method: 'POST', body: { projectId: project.projectId, consent: true } })
window.location.href = url
} catch (err) {
error.textContent = err.message
error.classList.remove('hidden')
button.disabled = !box.checked
}
})
}
function statusCard(feature, setupEstimate) {
const rows = [
feature.ticketWoid ? `<div><dt>Ticketnummer</dt><dd>#${esc(feature.ticketWoid)}</dd></div>` : '',
feature.orderedAt ? `<div><dt>Bestellt am</dt><dd>${esc(fmtDay(feature.orderedAt))}</dd></div>` : '',
setupEstimate ? `<div><dt>Zeiteinschätzung</dt><dd>${esc(setupEstimate)}</dd></div>` : '',
].join('')
const invoice = safeUrl(feature.invoiceUrl)
return `
<div class="kb-panel fn-status">
<span class="fn-pill fn-pill-setup">In Einrichtung</span>
<h3>Wir richten deine News-Pinnwand ein</h3>
<p>${feature.state === 'ordered' ? 'Sobald deine Zahlung bei uns ankommt, legen wir los.' : 'Sobald sie fertig ist, kannst du hier deine ersten Beiträge schreiben.'}</p>
${rows ? `<dl class="fn-facts">${rows}</dl>` : ''}
<div class="abo-actions">
${invoice ? `<a class="kb-btn-outline" href="${esc(invoice)}" target="_blank" rel="noopener">Rechnung ansehen</a>` : ''}
<button type="button" class="kb-btn-outline" data-view="chat">Frage dazu? Chat öffnen</button>
</div>
</div>`
}
/* -------------------------------------------------------------- Editor --- */
function renderEditor(stage, project) {
const pid = project.projectId
const q = `projectId=${encodeURIComponent(pid)}`
const imageUrl = (imageId, size = 's') => `/api/news/image/${encodeURIComponent(pid)}/${encodeURIComponent(imageId)}-${size}.webp`
const fresh = () => ({ editId: '', type: 'combo', title: '', text: '', paper: 'sun', duration: '1m', date: '', imageId: '', imageSrc: '', uploading: false })
let ed = fresh()
let liveUrl = ''
let list = { hanging: [], expired: [] }
stage.innerHTML = `
<div class="nw">
<div class="nw-form">
<div class="kb-panel nw-card">
<p class="nw-step" id="nw-step-title">Neuer Zettel</p>
<h3>Was möchtest du aufhängen?</h3>
<div class="nw-types">
${TYPES.map(([key, label, sub, icon]) => `
<button type="button" class="nw-type" data-type="${key}" aria-pressed="false">
<span class="nw-type-ico"><svg viewBox="0 0 24 24" aria-hidden="true">${icon}</svg></span>
${label}<small>${sub}</small>
</button>`).join('')}
</div>
</div>
<div class="kb-panel nw-card">
<div class="nw-field" id="nw-photo-field">
<label>Foto</label>
<div class="nw-drop" id="nw-drop">
<div class="nw-drop-img hidden" id="nw-drop-img"><img alt=""></div>
<div class="nw-drop-btns">
<label class="kb-btn-outline nw-only-phone"><input type="file" accept="${PHOTO_ACCEPT}" capture="environment" hidden>Foto aufnehmen</label>
<label class="kb-btn-outline" id="nw-pick"><input type="file" accept="${PHOTO_ACCEPT}" hidden><span>Foto auswählen</span></label>
</div>
<p class="nw-hint" id="nw-photo-hint">JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.</p>
</div>
<p class="nw-err" data-err="photo" role="alert"></p>
</div>
<div class="nw-field">
<label for="nw-title" id="nw-title-label">Überschrift (freiwillig)</label>
<input type="text" id="nw-title" maxlength="${TITLE_MAX}" autocomplete="off">
<p class="nw-err" data-err="title" role="alert"></p>
</div>
<div class="nw-field" id="nw-text-field">
<label for="nw-text">Text</label>
<textarea id="nw-text" rows="4" maxlength="${TEXT_MAX}"></textarea>
<p class="nw-hint nw-hint-row"><span>Kurz und herzlich wirkt am besten.</span><span id="nw-count">0 / ${TEXT_MAX}</span></p>
<p class="nw-err" data-err="text" role="alert"></p>
</div>
<div class="nw-field" id="nw-paper-field">
<label>Zettelfarbe</label>
<div class="nw-swatches">
${PAPERS.map(([key, label]) => `<button type="button" class="nw-sw nw-paper-${key}" data-paper="${key}" aria-pressed="false" aria-label="${label}" title="${label}"></button>`).join('')}
</div>
<p class="nw-err" data-err="paper" role="alert"></p>
</div>
<div class="nw-field">
<label for="nw-duration">Wie lange soll der Zettel hängen?</label>
<select id="nw-duration">${DURATIONS.map(([key, label]) => `<option value="${key}"${key === '1m' ? ' selected' : ''}>${label}</option>`).join('')}</select>
<input type="date" id="nw-date" class="hidden" min="${berlinIsoDay(1)}" max="${berlinIsoDay(365)}" aria-label="Letzter Tag">
<p class="nw-err" data-err="duration" role="alert"></p>
</div>
<p class="nw-err" data-err="_" role="alert"></p>
<p class="fn-thanks hidden" id="nw-done" role="status"></p>
<div class="abo-actions nw-actions">
<button type="button" class="abo-btn" id="nw-publish">Aufhängen</button>
<button type="button" class="kb-btn-outline hidden" id="nw-cancel">Abbrechen</button>
</div>
</div>
</div>
<div class="nw-side">
<button type="button" class="kb-btn-outline nw-prev-toggle" id="nw-prev-toggle" aria-expanded="false">So sieht dein Zettel aus</button>
<div class="kb-panel nw-prev" id="nw-prev-box">
<p class="nw-prev-label"><i></i>Vorschau: so sehen es alle</p>
<div id="nw-preview"></div>
</div>
<div class="kb-panel nw-lists" id="nw-lists"><p class="kb-empty">Lade…</p></div>
</div>
</div>`
const $ = (sel) => stage.querySelector(sel)
const $$ = (sel) => [...stage.querySelectorAll(sel)]
const setErr = (field, message) => {
const target = $(`[data-err="${field}"]`) || $('[data-err="_"]')
if (target) target.textContent = message || ''
}
const clearErrs = () => $$('[data-err]').forEach((el) => { el.textContent = '' })
const errField = (field) => ({ imageId: 'photo', date: 'duration' }[field] || field || '_')
function previewHtml() {
const hasPhoto = ed.type !== 'note'
const photo = hasPhoto
? (ed.imageSrc ? `<img src="${esc(ed.imageSrc)}" alt="">` : '<span class="nw-ph">Foto</span>')
: ''
const title = ed.title.trim()
if (ed.type === 'photo') {
return `<figure class="nw-polaroid">${photo}<figcaption><b>${esc(title)}</b><span>Heute</span></figcaption></figure>`
}
const note = `<div class="nw-paper nw-paper-${ed.paper}"><span class="nw-date">Heute</span>${title ? `<h4>${esc(title)}</h4>` : ''}<p>${esc(ed.text)}</p></div>`
return ed.type === 'combo' ? `<div class="nw-combo"><figure class="nw-polaroid">${photo}</figure>${note}</div>` : note
}
function sync() {
$$('.nw-type').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.type === ed.type)))
$$('.nw-sw').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.paper === ed.paper)))
$('#nw-photo-field').classList.toggle('hidden', ed.type === 'note')
$('#nw-text-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-paper-field').classList.toggle('hidden', ed.type === 'photo')
$('#nw-title-label').textContent = ed.type === 'photo' ? 'Bildunterschrift (freiwillig)' : 'Überschrift (freiwillig)'
$('#nw-count').textContent = `${ed.text.length} / ${TEXT_MAX}`
$('#nw-date').classList.toggle('hidden', ed.duration !== 'date')
$('#nw-step-title').textContent = ed.editId ? 'Zettel ändern' : 'Neuer Zettel'
$('#nw-publish').textContent = ed.editId ? 'Änderungen speichern' : 'Aufhängen'
$('#nw-cancel').classList.toggle('hidden', !ed.editId)
const img = $('#nw-drop-img')
img.classList.toggle('hidden', !ed.imageSrc)
if (ed.imageSrc) img.querySelector('img').src = ed.imageSrc
$('#nw-pick span').textContent = ed.imageSrc ? 'Anderes Foto wählen' : 'Foto auswählen'
$('#nw-publish').disabled = ed.uploading
$('#nw-preview').innerHTML = previewHtml()
}
function reset() {
ed = fresh()
$('#nw-title').value = ''
$('#nw-text').value = ''
$('#nw-duration').value = '1m'
$('#nw-date').value = ''
clearErrs()
sync()
}
// Formular
$$('.nw-type').forEach((b) => b.addEventListener('click', () => { ed.type = b.dataset.type; sync() }))
$$('.nw-sw').forEach((b) => b.addEventListener('click', () => { ed.paper = b.dataset.paper; sync() }))
$('#nw-title').addEventListener('input', (ev) => { ed.title = ev.target.value; sync() })
$('#nw-text').addEventListener('input', (ev) => { ed.text = ev.target.value; sync() })
$('#nw-duration').addEventListener('change', (ev) => { ed.duration = ev.target.value; sync() })
$('#nw-date').addEventListener('change', (ev) => { ed.date = ev.target.value })
$('#nw-prev-toggle').addEventListener('click', (ev) => {
const open = $('#nw-prev-box').classList.toggle('is-open')
ev.currentTarget.setAttribute('aria-expanded', String(open))
})
$('#nw-cancel').addEventListener('click', reset)
// Foto: gleich hochladen, der Server liefert die imageId
$$('#nw-photo-field input[type=file]').forEach((input) => input.addEventListener('change', async () => {
const file = input.files[0]
input.value = ''
if (!file) return
setErr('photo', '')
if (file.size > PHOTO_MAX_BYTES) { setErr('photo', 'Das Foto ist größer als 10 MB.'); return }
const previous = { imageId: ed.imageId, imageSrc: ed.imageSrc }
ed.uploading = true
ed.imageSrc = URL.createObjectURL(file)
sync()
$('#nw-photo-hint').textContent = 'Foto wird hochgeladen…'
try {
const { imageId } = await request(`/api/news/image?${q}`, { method: 'POST', raw: file })
ed.imageId = imageId
} catch (err) {
setErr('photo', err.message)
Object.assign(ed, previous)
} finally {
ed.uploading = false
$('#nw-photo-hint').textContent = 'JPEG, PNG, WebP oder iPhone-Foto, bis 10 MB.'
sync()
}
}))
$('#nw-publish').addEventListener('click', async () => {
clearErrs()
$('#nw-done').classList.add('hidden')
const body = { type: ed.type, title: ed.title.trim(), text: ed.type === 'photo' ? '' : ed.text, paper: ed.paper, duration: ed.duration }
if (ed.duration === 'date') body.date = ed.date
if (ed.type !== 'note') body.imageId = ed.imageId
const wasEdit = Boolean(ed.editId)
const button = $('#nw-publish')
button.disabled = true
try {
if (wasEdit) await request(`/api/news/${encodeURIComponent(ed.editId)}?${q}`, { method: 'PATCH', body })
else await request('/api/news', { method: 'POST', body: { ...body, projectId: pid } })
reset()
const done = $('#nw-done')
done.textContent = wasEdit ? 'Gespeichert.' : 'Hängt jetzt an deiner Pinnwand.'
done.classList.remove('hidden')
await loadList()
} catch (err) {
setErr(errField(err.field), err.message)
} finally {
button.disabled = ed.uploading
}
})
// Listen "Hängt gerade" / "Abgelaufen"
const untilText = (p) => (p.visibleUntil ? `bis ${fmtDay(p.visibleUntil)}` : 'bis du sie abnimmst')
function rowHtml(p, expired) {
const label = p.title || (p.text ? p.text.slice(0, 60) : 'Foto')
const thumb = p.imageId
? `<img class="nw-thumb" src="${esc(imageUrl(p.imageId))}" alt="">`
: `<span class="nw-thumb nw-paper-${esc(p.paper)}">T</span>`
const meta = expired
? (p.takenDownAt ? `Abgenommen am ${fmtDay(p.takenDownAt)}` : `Abgelaufen am ${fmtDay(p.visibleUntil)}`)
: `Seit ${fmtDay(p.visibleFrom)} · ${untilText(p)}`
const actions = expired
? `<select class="nw-rehang-dur" aria-label="Anzeigedauer">${REHANG_DURATIONS.map(([k, l]) => `<option value="${k}"${k === '1m' ? ' selected' : ''}>${l}</option>`).join('')}</select>
<button type="button" data-rehang>Wieder aufhängen</button>`
: '<button type="button" data-edit>Ändern</button><button type="button" data-takedown>Abnehmen</button>'
return `
<li class="nw-row" data-id="${esc(p.$id)}">
${thumb}
<div class="nw-row-text"><b>${esc(label)}</b><small>${esc(meta)}</small></div>
<div class="nw-row-actions">${actions}<button type="button" class="nw-del" data-del>Löschen</button></div>
<p class="nw-err nw-row-err" role="alert"></p>
</li>`
}
function renderLists() {
const box = $('#nw-lists')
const site = safeUrl(liveUrl)
box.innerHTML = `
<div class="nw-lists-head">
<h3>Hängt gerade</h3>
${site ? `<a class="kb-btn-outline" href="${esc(site)}" target="_blank" rel="noopener">Auf meiner Website ansehen</a>` : ''}
</div>
${list.hanging.length ? `<ul class="nw-rows">${list.hanging.map((p) => rowHtml(p, false)).join('')}</ul>` : '<p class="nw-empty">Im Moment hängt nichts. Schreib deinen ersten Zettel links.</p>'}
${list.expired.length ? `<h3 class="nw-expired-title">Abgelaufen</h3><p class="nw-hint">Nicht mehr auf deiner Website zu sehen. Du kannst sie wieder aufhängen.</p><ul class="nw-rows">${list.expired.map((p) => rowHtml(p, true)).join('')}</ul>` : ''}`
box.querySelectorAll('.nw-row').forEach(wireRow)
}
function wireRow(row) {
const id = row.dataset.id
const all = [...list.hanging, ...list.expired]
const post = all.find((p) => p.$id === id)
const rowErr = row.querySelector('.nw-row-err')
const run = async (fn) => {
rowErr.textContent = ''
try {
await fn()
await loadList()
} catch (err) {
rowErr.textContent = err.message
}
}
row.querySelector('[data-edit]')?.addEventListener('click', () => startEdit(post))
row.querySelector('[data-takedown]')?.addEventListener('click', () => run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'takeDown' } })))
row.querySelector('[data-rehang]')?.addEventListener('click', () => {
const duration = row.querySelector('.nw-rehang-dur').value
run(() => request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'PATCH', body: { action: 'rehang', duration } }))
})
// Loeschen mit Rueckfrage in der Zeile (kein confirm())
row.querySelector('[data-del]').addEventListener('click', () => {
const actions = row.querySelector('.nw-row-actions')
actions.innerHTML = '<span class="nw-confirm"><span>Wirklich löschen?</span><button type="button" class="nw-del" data-yes>Ja, löschen</button><button type="button" data-cancel>Abbrechen</button></span>'
actions.querySelector('[data-cancel]').addEventListener('click', renderLists)
actions.querySelector('[data-yes]').addEventListener('click', () => run(async () => {
await request(`/api/news/${encodeURIComponent(id)}?${q}`, { method: 'DELETE' })
if (ed.editId === id) reset()
}))
})
}
function startEdit(post) {
clearErrs()
$('#nw-done').classList.add('hidden')
ed = {
editId: post.$id, type: post.type, title: post.title || '', text: post.text || '', paper: post.paper,
duration: post.duration, date: post.duration === 'date' && post.visibleUntil ? new Date(post.visibleUntil).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) : '',
imageId: post.imageId || '', imageSrc: post.imageId ? imageUrl(post.imageId, 'l') : '', uploading: false,
}
$('#nw-title').value = ed.title
$('#nw-text').value = ed.text
$('#nw-duration').value = ed.duration
$('#nw-date').value = ed.date
sync()
$('#nw-step-title').scrollIntoView({ block: 'start', behavior: 'smooth' })
}
async function loadList() {
try {
const data = await request(`/api/news?${q}`)
list = { hanging: data.hanging || [], expired: data.expired || [] }
liveUrl = data.liveUrl || ''
renderLists()
} catch (err) {
$('#nw-lists').innerHTML = `<p class="muted">Beiträge konnten nicht geladen werden: ${esc(err.message)}</p>`
}
}
sync()
return loadList()
}
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leistungsbeschreibung Funktionen – News-Pinnwand – WEBklar</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
p, li { margin: 6px 0; }
ul { padding-left: 24px; }
.meta { color: #6b6b70; font-size: 14px; }
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
td:first-child { font-weight: 600; width: 30%; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm; }
body { background: #fff; font-size: 10pt; line-height: 1.45; }
main { max-width: none; padding: 0; }
nav.top { display: none; }
h1 { font-size: 20pt; }
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
a { color: inherit; text-decoration: none; }
.box, tr { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html#news">Datenschutz</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
<p class="meta">Stand 30.09.2026</p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Leistung</td><td>Einbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal</td></tr>
<tr><td>Preis</td><td>30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten</td></tr>
<tr><td>Dauer des Einbaus</td><td>meist innerhalb von 1–2 Wochen (keine verbindliche Zusage)</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)</td></tr>
</table>
</div>
<h2 id="leistung">1. Leistung</h2>
<p>Wir bauen die News-Pinnwand in die Website ein, die Sie für das gewählte Projekt bei uns im Kundenportal führen. Die Pinnwand zeigt Ihre Beiträge als Zettel mit Titel, Text und Foto; pro Seite erscheinen sechs Beiträge, die neuesten zuerst. Die Beiträge schreiben, ändern und löschen Sie selbst im Kundenportal im Bereich „Funktionen“. Sie wählen je Beitrag Zettelfarbe und Anzeigedauer; danach verschwindet der Beitrag von Ihrer Website. Hochgeladene Fotos werden verkleinert, alle Metadaten einschließlich Aufnahmeort (GPS) werden entfernt.</p>
<p>Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.</p>
<h2 id="preis">2. Preis</h2>
<p>Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.</p>
<h2 id="einbau">3. Einbau und Freischaltung</h2>
<p>Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.</p>
<h2 id="ende">4. Ende der Zusammenarbeit</h2>
<p>Auf Ihren Wunsch schalten wir die Pinnwand ab; das tun wir auch, wenn die Zusammenarbeit endet und wir die Funktion bei Ihnen abschalten. Ihre Beiträge und Fotos werden gelöscht, wenn Sie sie selbst löschen, andernfalls nach den Fristen der Datenschutzerklärung (<a href="/datenschutz.html#news">Abschnitt 6a</a>, dort Abschnitt 7).</p>
<h2 id="inhalte">5. Ihre Inhalte</h2>
<p>Sie sind für die Inhalte Ihrer Beiträge und Fotos verantwortlich. Sie versichern, dass Sie die nötigen Rechte an Texten und Fotos haben und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.</p>
<h2 id="widerruf">6. Widerrufsrecht</h2>
<p>Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über <a href="/widerrufen.html">project.webklar.com/widerrufen.html</a>.</p>
<p>Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.</p>
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die <a href="/abo-bedingungen.html">Vertragsbedingungen</a> und die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
</main>
</body>
</html>
+1
View File
@@ -20,6 +20,7 @@
<label class="radio"><input type="radio" name="order" value="abschluss" data-text="Abschluss meines Abos" checked> Abschluss meines Abos</label> <label class="radio"><input type="radio" name="order" value="abschluss" data-text="Abschluss meines Abos" checked> Abschluss meines Abos</label>
<label class="radio"><input type="radio" name="order" value="wechsel" data-text="Wechsel ins Wartungs-Abo"> Wechsel ins Wartungs-Abo</label> <label class="radio"><input type="radio" name="order" value="wechsel" data-text="Wechsel ins Wartungs-Abo"> Wechsel ins Wartungs-Abo</label>
<label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label> <label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label>
<label class="radio"><input type="radio" name="order" value="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
</fieldset> </fieldset>
<label for="name" data-label-for="name" data-label="Name">Name</label> <label for="name" data-label-for="name" data-label="Name">Name</label>
+61 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits * Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
* (Uebernahmegutschriften) und aboOffers (individuelle Angebote) in woms-database an und * (Uebernahmegutschriften), aboOffers (individuelle Angebote), portalFeatures (Portal-Funktionen je
* Projekt) und newsPosts (News-Pinnwand) in woms-database an und
* ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto"). * ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs * Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
@@ -244,6 +245,65 @@ await ensureCollection(
] ]
) )
// Portal-Funktionen je Projekt (Zustand, Kauf, Zustimmungsnachweis). Die Collection existiert in
// Produktion teils schon; ensureCollection ueberspringt Vorhandenes (409) und ergaenzt Fehlendes.
await ensureCollection(
'portalFeatures',
'Portal-Funktionen',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'projectId', size: 64 },
{ type: 'string', key: 'featureKey', size: 64 },
{ type: 'boolean', key: 'enabled' },
{ type: 'boolean', key: 'unlockedByPurchase' },
{ type: 'string', key: 'purchaseStatus', size: 64 },
{ type: 'string', key: 'state', size: 16 },
{ type: 'string', key: 'siteKey', size: 16 },
{ type: 'string', key: 'ticketId', size: 64 },
{ type: 'string', key: 'stripeSessionId', size: 64 },
{ type: 'string', key: 'stripeInvoiceId', size: 64 },
{ type: 'string', key: 'invoiceUrl', size: 500 },
{ type: 'datetime', key: 'orderedAt' },
{ type: 'datetime', key: 'activatedAt' },
{ type: 'datetime', key: 'disabledAt' },
{ type: 'string', key: 'consentText', size: 1000 },
{ type: 'datetime', key: 'consentAt' },
{ type: 'string', key: 'consentIp', size: 64 },
{ type: 'string', key: 'consentUserAgent', size: 300 },
{ type: 'string', key: 'adminNote', size: 300 },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
{ key: 'idx_state', type: 'key', attributes: ['state'] },
{ key: 'idx_sitekey', type: 'unique', attributes: ['siteKey'] },
{ key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] },
]
)
// News-Pinnwand: Beitraege je Projekt
await ensureCollection(
'newsPosts',
'News-Beiträge',
[
{ type: 'string', key: 'customerId', size: 64 },
{ type: 'string', key: 'projectId', size: 64 },
{ type: 'string', key: 'type', size: 8 },
{ type: 'string', key: 'title', size: 60 },
{ type: 'string', key: 'text', size: 400 },
{ type: 'string', key: 'imageId', size: 64 },
{ type: 'string', key: 'paper', size: 8 },
{ type: 'string', key: 'duration', size: 8 },
{ type: 'datetime', key: 'visibleFrom' },
{ type: 'datetime', key: 'visibleUntil' },
{ type: 'datetime', key: 'takenDownAt' },
{ type: 'datetime', key: 'createdAt' },
{ type: 'datetime', key: 'updatedAt' },
],
[
{ key: 'idx_project_from', type: 'key', attributes: ['projectId', 'visibleFrom'] },
]
)
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen // Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
await ensure('worksheets.countsToPlan', () => await ensure('worksheets.countsToPlan', () =>
aw(`${col('worksheets')}/attributes/boolean`, { aw(`${col('worksheets')}/attributes/boolean`, {
+8 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher): legt fehlende * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-Einrichtung als Einmalpreis): legt fehlende
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
@@ -17,7 +17,7 @@ import { config } from '../server/config.js'
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
import { PRODUCT_KEYS } from '../server/services/products.js' import { PRODUCT_KEYS } from '../server/services/products.js'
const KEYS = [...PRODUCT_KEYS, 'mailStorage'] // ['wartung', 'hosting', 'mailStorage'] const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup']
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
export function parseArgs(argv) { export function parseArgs(argv) {
@@ -28,7 +28,9 @@ export function parseArgs(argv) {
} }
function catalogEntry(key) { function catalogEntry(key) {
return key === 'mailStorage' ? config.mailStorage : config.products[key] if (key === 'mailStorage') return config.mailStorage
if (key === 'newsSetup') return config.newsSetup
return config.products[key]
} }
async function findPrice(lookupKey) { async function findPrice(lookupKey) {
@@ -42,7 +44,9 @@ async function checkPrice(key) {
if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` } if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` }
const mismatches = [] const mismatches = []
if (price.unit_amount !== catalog.amountCents) mismatches.push(`Betrag ${price.unit_amount} statt ${catalog.amountCents}`) if (price.unit_amount !== catalog.amountCents) mismatches.push(`Betrag ${price.unit_amount} statt ${catalog.amountCents}`)
if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`) if (catalog.oneTime) {
if (price.recurring) mismatches.push('Preis ist wiederkehrend statt einmalig')
} else if (price.recurring?.interval !== 'month') mismatches.push(`recurring.interval "${price.recurring?.interval}" statt "month"`)
if (price.tax_behavior !== 'inclusive') mismatches.push(`tax_behavior "${price.tax_behavior}" statt "inclusive"`) if (price.tax_behavior !== 'inclusive') mismatches.push(`tax_behavior "${price.tax_behavior}" statt "inclusive"`)
// Important 2: Produkt ohne Bild faellt in --pruefen auf (Checkout zeigt sonst kein Bild) // Important 2: Produkt ohne Bild faellt in --pruefen auf (Checkout zeigt sonst kein Bild)
const productId = typeof price.product === 'string' ? price.product : price.product?.id const productId = typeof price.product === 'string' ? price.product : price.product?.id
+24
View File
@@ -88,6 +88,16 @@ const mailStorage = {
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 }, hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
} }
// News-Pinnwand: einmalige Einrichtung (Einmalpreis, kein Abo)
const newsSetup = {
key: 'newsSetup',
name: 'News-Pinnwand – Einrichtung',
amountCents: 3000,
lookupKey: 'webklar_news_einrichtung_3000',
oneTime: true,
priceNickname: 'News-Einrichtung 30 €',
}
export const config = { export const config = {
port: Number(process.env.PORT) || 3000, port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '', sessionSecret: process.env.SESSION_SECRET || '',
@@ -118,6 +128,7 @@ export const config = {
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits', takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers', aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents', contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
}, },
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
@@ -132,6 +143,14 @@ export const config = {
products, products,
mailStorage, mailStorage,
newsSetup,
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
funktionen: {
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText:
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an. // Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung). // Die Werte kommen aus dem Produktkatalog (products.wartung).
@@ -201,6 +220,11 @@ export const config = {
rejectedRetentionDays: 183, rejectedRetentionDays: 183,
}, },
// News-Pinnwand: Fotos liegen ausserhalb von public/ (gitignored, ueberleben Deploys)
news: {
uploadDir: process.env.NEWS_UPLOAD_DIR || path.join(process.cwd(), 'upload-data', 'news'),
},
// Chat-Anhaenge (Bilder, Dateien, Kamerafotos, Sprachnachrichten): liegen ausserhalb von public/ // Chat-Anhaenge (Bilder, Dateien, Kamerafotos, Sprachnachrichten): liegen ausserhalb von public/
// (gitignored, ueberleben Deploys wie preview-data/) // (gitignored, ueberleben Deploys wie preview-data/)
chat: { chat: {
+29
View File
@@ -12,9 +12,12 @@ import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js' import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js' import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js' import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js' import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgePendingChatUploads } from './services/chatAttachments.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js' import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js' import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js' import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -23,6 +26,8 @@ import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js' import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js' import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import adminSwitchLinkRoutes from './routes/admin/switchLink.js' import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -56,10 +61,13 @@ app.use((req, res, next) => {
app.use('/api/auth', authRoutes) app.use('/api/auth', authRoutes)
app.use('/api/projects', projectsRoutes) app.use('/api/projects', projectsRoutes)
app.use('/api/features', featuresRoutes) app.use('/api/features', featuresRoutes)
app.use('/api/public/news', publicNewsRoutes)
app.use('/api/news', newsRoutes)
app.use('/api', customerDataRoutes) app.use('/api', customerDataRoutes)
app.use('/api/chat', chatRoutes) app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes) app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes) app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
@@ -83,6 +91,12 @@ app.get('/api/health', async (_req, res) => {
}) })
}) })
// Einbau-Skript fuer Kundenwebsites: darf von fremden Seiten geladen werden, 5 Minuten Cache
app.use('/embed/', (_req, res, next) => {
res.set('Cache-Control', 'public, max-age=300')
res.set('Access-Control-Allow-Origin', '*')
next()
})
app.use(express.static(publicDir)) app.use(express.static(publicDir))
app.get('/dashboard.html', (req, res, next) => { app.get('/dashboard.html', (req, res, next) => {
@@ -130,6 +144,21 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message)) .catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeChat, 90 * 1000).unref() setTimeout(purgeChat, 90 * 1000).unref()
setInterval(purgeChat, 60 * 60 * 1000).unref() setInterval(purgeChat, 60 * 60 * 1000).unref()
// News-Fotos, die nie an einen Beitrag gebunden wurden, nach 24 Stunden loeschen (stuendlich)
const purgeNewsImages = () =>
purgeUnclaimedNewsImages()
.then((n) => n && console.log(`[news] ${n} nicht verwendete Fotos gelöscht`))
.catch((err) => console.warn('[news] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeNewsImages, 120 * 1000).unref()
setInterval(purgeNewsImages, 60 * 60 * 1000).unref()
// Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten)
const activateFeatures = () =>
runActivation()
.then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`))
.catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
setInterval(activateFeatures, 5 * 60 * 1000).unref()
}) })
server.on('error', (err) => { server.on('error', (err) => {
@@ -8,7 +8,7 @@
Absender: „WEBklar“ <Portal-Postfach> Absender: „WEBklar“ <Portal-Postfach>
Empfänger: E-Mail aus dem Formular · BCC intern Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, pruefen Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist).
--> -->
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -50,6 +50,9 @@ a{color:#111111;}
<!-- WENN zusatzspeicher --> <!-- WENN zusatzspeicher -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</div></td></tr></table></td></tr> <tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher --> <!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; war die Einrichtung auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist abgeschlossen, verrechnen wir gegebenenfalls einen Wertersatz (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN pruefen --> <!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr> <tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
<!-- ENDE pruefen --> <!-- ENDE pruefen -->
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K15 · Bestellung Funktion (News-Pinnwand)
Betreff: Deine Bestellung: News-Pinnwand
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{zeit}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung
rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Bestellung: News-Pinnwand</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Bestellung der News-Pinnwand ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bestätigung</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Bestellung eingegangen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: News-Pinnwand</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.</div></td></tr></table></td></tr>
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html#news" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K16 · Funktion bereit (News-Pinnwand)
Betreff: Deine News-Pinnwand ist bereit
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine News-Pinnwand ist bereit</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine News-Pinnwand ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Bereit</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; News-Pinnwand</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine News-Pinnwand ist bereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Beitrag schreiben &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -8,7 +8,7 @@
Absender: „WEBklar Kundenportal“ <Portal-Postfach> Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen
--> -->
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
@@ -60,6 +60,9 @@ a{color:#111111;}
<!-- WENN zusatzspeicher --> <!-- WENN zusatzspeicher -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr> <tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher --> <!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Admin-Bereich abschalten. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN keine_frist --> <!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr> <tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE keine_frist --> <!-- ENDE keine_frist -->
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T16 · Funktion bestellt (News-Pinnwand)
Betreff: Neue Funktion bestellt: News-Pinnwand
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{woid}}, {{sitekey}}, {{sitzung}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert
ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt.
zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Neue Funktion bestellt: News-Pinnwand</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}}: News-Pinnwand, Projekt {{projekt}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Funktionen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Funktion bestellt: News-Pinnwand</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Ticket</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{woid}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Site-Key</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{sitekey}}</td></tr></table></td></tr>
<!-- WENN zahlung_fehlgeschlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zahlung fehlgeschlagen</div><div class="mut" style="color:#4a4843;">Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.</div></td></tr></table></td></tr>
<!-- ENDE zahlung_fehlgeschlagen -->
<!-- WENN ungeklaert -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bezahlt, aber nicht zuzuordnen</div><div class="mut" style="color:#4a4843;">Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: {{sitzung}}</div></td></tr></table></td></tr>
<!-- ENDE ungeklaert -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://project.webklar.com/admin.html" style="color:#4a4843;text-decoration:underline;">Admin-Bereich</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+52 -8
View File
@@ -18,6 +18,7 @@ import {
reconcilePendingSwitch, reconcilePendingSwitch,
switchToWartung, switchToWartung,
} from '../services/planSwitch.js' } from '../services/planSwitch.js'
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
import { import {
ACTIVE_STATUSES, ACTIVE_STATUSES,
@@ -518,6 +519,11 @@ async function recordAbschluss(sub, session) {
async function handleStripeEvent(event) { async function handleStripeEvent(event) {
const obj = event.data.object const obj = event.data.object
// Einmalkauf einer Funktion (News-Pinnwand): eigener Weg, der Abo-Zweig unten bleibt unberuehrt
if (isFeatureCheckoutEvent(event)) {
await handleFeatureCheckoutEvent(event)
return
}
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': { case 'checkout.session.completed': {
if (obj.mode !== 'subscription' || !obj.subscription) return if (obj.mode !== 'subscription' || !obj.subscription) return
@@ -698,13 +704,13 @@ export function publicRateLimitState() {
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
const CONTRACTS = ['abo', 'zusatzspeicher'] const CONTRACTS = ['abo', 'zusatzspeicher']
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher'] const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion']
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
/** /**
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
*/ */
function validDeclaration(body) { function validDeclaration(body) {
@@ -776,6 +782,7 @@ function contractLabel(contract, sub) {
function orderLabel(order, sub, { customer = false } = {}) { function orderLabel(order, sub, { customer = false } = {}) {
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
const abo = productNameOf(sub) const abo = productNameOf(sub)
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
} }
@@ -1061,6 +1068,25 @@ async function withdrawOrder(sub, d, receivedAt) {
} }
} }
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
/**
* Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags-
* ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team
* bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand.
*/
async function withdrawFeature(d, receivedAt) {
const customer =
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
const orders = customer ? await paidFeatureOrders(customer.$id) : []
const ends = orders.filter((o) => o.orderedAt).map((o) => withdrawalDeadline(o.orderedAt).endsAt)
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
const lines = orders
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
.join('\n')
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
}
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ /** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
function describeWithdrawn(events) { function describeWithdrawn(events) {
return events return events
@@ -1079,12 +1105,20 @@ router.post('/public/withdraw', async (req, res) => {
let sub = null let sub = null
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
// Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
const feature = d.order === 'funktion'
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
try { try {
if (feature) {
fn = await withdrawFeature(d, receivedAt)
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
} else {
sub = await findActiveSubscriptionByEmail(d.email) sub = await findActiveSubscriptionByEmail(d.email)
if (sub) { if (sub) {
result = await withdrawOrder(sub, d, receivedAt) result = await withdrawOrder(sub, d, receivedAt)
sub = result.sub || sub sub = result.sub || sub
} }
}
} catch (err) { } catch (err) {
console.error('[abo] Widerruf nicht verarbeitbar:', err.message) console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
} }
@@ -1097,7 +1131,7 @@ router.post('/public/withdraw', async (req, res) => {
values: { values: {
name: d.name, name: d.name,
bestellung: orderLabel(d.order, sub, { customer: true }), bestellung: orderLabel(d.order, sub, { customer: true }),
produkt: productLabelOf(sub), produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
eingang_zeitpunkt: fmtDateTime(receivedAt), eingang_zeitpunkt: fmtDateTime(receivedAt),
email: d.email, email: d.email,
kundennummer: d.customerNumber, kundennummer: d.customerNumber,
@@ -1107,7 +1141,8 @@ router.post('/public/withdraw', async (req, res) => {
abschluss: inTime && d.order === 'abschluss', abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel', wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher', zusatzspeicher: inTime && d.order === 'zusatzspeicher',
pruefen: !inTime, funktion: feature,
pruefen: !inTime && !feature,
}, },
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
@@ -1119,11 +1154,19 @@ router.post('/public/withdraw', async (req, res) => {
name: d.name, name: d.name,
kundennummer: d.customerNumber, kundennummer: d.customerNumber,
bestellung: orderLabel(d.order, sub), bestellung: orderLabel(d.order, sub),
produkt: productLabelOf(sub), produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
stripe_abo_id: sub?.stripeSubscriptionId || '', stripe_abo_id: sub?.stripeSubscriptionId || '',
abo_beginn: fmtDate(sub?.startedAt), abo_beginn: fmtDate(sub?.startedAt),
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
rechtzeitig: !sub rechtzeitig: feature
? !fn.customer
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
: !fn.orders.length
? 'ungeprüft (keine bezahlte Funktion gefunden)'
: inTime
? 'RECHTZEITIG'
: 'NACH Fristende, vor einer Ablehnung bitte prüfen'
: !sub
? 'ungeprüft (kein Abo gefunden)' ? 'ungeprüft (kein Abo gefunden)'
: inTime : inTime
? 'RECHTZEITIG' ? 'RECHTZEITIG'
@@ -1136,18 +1179,19 @@ router.post('/public/withdraw', async (req, res) => {
: result.deadline : result.deadline
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)' ? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
: 'ungeprüft (keine Bestellung dieser Art gefunden)', : 'ungeprüft (keine Bestellung dieser Art gefunden)',
ereignisse: describeWithdrawn(result.withdrawn), ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
pruefhinweis, pruefhinweis,
}, },
blocks: { blocks: {
kundennummer: Boolean(d.customerNumber), kundennummer: Boolean(d.customerNumber),
abo_gefunden: Boolean(sub), abo_gefunden: Boolean(sub),
auf_kuendigung_gesetzt: result.canceled, auf_kuendigung_gesetzt: result.canceled,
kein_abo_gefunden: !sub, kein_abo_gefunden: !sub && !feature,
kuendigung_fehlgeschlagen: result.cancelFailed, kuendigung_fehlgeschlagen: result.cancelFailed,
abschluss: inTime && d.order === 'abschluss', abschluss: inTime && d.order === 'abschluss',
wechsel: inTime && d.order === 'wechsel', wechsel: inTime && d.order === 'wechsel',
zusatzspeicher: inTime && d.order === 'zusatzspeicher', zusatzspeicher: inTime && d.order === 'zusatzspeicher',
funktion: feature,
keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn, keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn,
nachweis_fehlt: Boolean(sub) && !result.event, nachweis_fehlt: Boolean(sub) && !result.event,
wechsel_offen: result.openSwitchWithdrawn, wechsel_offen: result.openSwitchWithdrawn,
+86
View File
@@ -0,0 +1,86 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { withLocks } from '../../services/keyedLock.js'
import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js'
/**
* Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel.
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession.
*/
export const funktionenAdminRouter = Router()
const NOTE_MAX = 300
const adminName = (req) => req.session.adminName || req.session.name || 'Admin'
function sendError(res, err) {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[funktionen-admin]', err.message)
return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
}
/** Neuester Eintrag zuerst; aeltere Eintraege fallen vorn heraus, sobald 300 Zeichen voll sind. */
export function composeAdminNote(previous, { by, action, note }, now = new Date()) {
const entry = `${now.toISOString()} ${by}: ${action}${note ? ` – ${note}` : ''}`.slice(0, NOTE_MAX)
const older = String(previous || '')
return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
}
async function ticketExists(ticketId) {
if (!ticketId) return false
try {
await getDocument(config.collections.workorders, ticketId)
return true
} catch (err) {
if (err.status === 404) return false
throw err
}
}
async function adminView(customerId) {
const projects = await listCustomerFeatures(customerId)
for (const p of projects) {
const news = p.features.news
const doc = await getFeatureDoc(customerId, p.projectId, 'news')
news.adminNote = doc?.adminNote || ''
news.ticketMissing = news.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
}
return projects
}
funktionenAdminRouter.get('/:customerId', async (req, res) => {
try {
res.json({ projects: await adminView(req.params.customerId) })
} catch (err) {
sendError(res, err)
}
})
funktionenAdminRouter.post('/:customerId/news', async (req, res) => {
try {
const { customerId } = req.params
const { projectId, action } = req.body || {}
const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : ''
if (action !== 'activate' && action !== 'disable') {
return res.status(400).json({ error: 'Unbekannte Aktion' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Projekt fehlt' })
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, 'news')
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, 'news', { by, adminNote })
} else {
if (!doc) throw Object.assign(new Error('Für dieses Projekt gibt es noch keine News-Pinnwand'), { status: 409 })
await disable(doc.$id, { by, adminNote })
}
})
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) })
} catch (err) {
sendError(res, err)
}
})
+1 -1
View File
@@ -43,7 +43,7 @@ const waitingUploads = []
function startWaitingUploads() { function startWaitingUploads() {
while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()() while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()()
} }
function uploadSlot(_req, res, next) { export function uploadSlot(_req, res, next) {
let state = 'waiting' let state = 'waiting'
// 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens // 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens
res.once('close', () => { res.once('close', () => {
+1
View File
@@ -22,6 +22,7 @@ router.get('/', requireSession, async (req, res) => {
projectId: f.projectId || '', projectId: f.projectId || '',
featureKey: f.featureKey || '', featureKey: f.featureKey || '',
enabled: Boolean(f.enabled), enabled: Boolean(f.enabled),
state: f.state || (f.enabled ? 'active' : 'off'),
unlockedByPurchase: Boolean(f.unlockedByPurchase), unlockedByPurchase: Boolean(f.unlockedByPurchase),
purchaseStatus: f.purchaseStatus || '', purchaseStatus: f.purchaseStatus || '',
})) }))
+108
View File
@@ -0,0 +1,108 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { withLocks } from '../services/keyedLock.js'
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
import {
DISABLED_MESSAGE,
assertOwnProject,
backfillInvoiceLinks,
getFeatureDoc, listCustomerFeatures,
markOrdered,
runActivation,
} from '../services/siteFeatures.js'
const router = Router()
const DAY_MS = 24 * 60 * 60 * 1000
function fail(res, err) {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
}
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
router.get('/', requireSession, async (req, res) => {
try {
const customerId = getSessionCustomerId(req)
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
await backfillInvoiceLinks(customerId)
res.json({
projects: await listCustomerFeatures(customerId),
setupEstimate: config.funktionen.setupEstimate,
consentText: config.funktionen.consentText,
})
} catch (err) {
fail(res, err)
}
})
/**
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
*/
async function openOrderSession(doc) {
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
if (!doc.stripeSessionId) return null
let session
try {
session = await retrieveCheckoutSession(doc.stripeSessionId)
} catch (err) {
console.warn('[funktionen] Sitzung nicht abrufbar:', err.message)
if (Date.now() - new Date(doc.orderedAt || 0).getTime() < DAY_MS) throw conflict()
return null
}
if (session.status === 'open' && session.url) return { id: session.id, url: session.url }
if (session.status === 'expired') return null
throw conflict()
}
router.post('/news/checkout', requireSession, async (req, res) => {
try {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
}
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId)
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung
// richtet sich nach ihrer gespeicherten Stripe-Sitzung
const existing = await getFeatureDoc(customerId, projectId, 'news')
if (existing?.state === 'setup' || existing?.state === 'active') {
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
}
if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 })
if (existing?.state === 'ordered') {
const open = await openOrderSession(existing)
if (open) return open
}
const customer = await getDocument(config.collections.customers, customerId)
const now = new Date().toISOString()
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
await markOrdered({
customerId,
projectId,
key: 'news',
sessionId: created.id,
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
})
return created
})
res.json({ url: session.url })
} catch (err) {
fail(res, err)
}
})
export default router
+159
View File
@@ -0,0 +1,159 @@
import express, { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { NEWS_IMAGE_MAX_BYTES, newsImagePath, newsImageRateLimited, storeNewsImage } from '../services/newsImages.js'
import { createPost, deletePost, listPosts, updatePost } from '../services/newsPosts.js'
import { uploadSlot } from './chat.js'
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 30
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
function fail(res, err) {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[news]', err.message)
const body = { error: status >= 500 ? 'Interner Serverfehler' : err.message }
if (err.field) body.field = err.field
return res.status(status).json(body)
}
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = Date.now()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
function rejectViewAs(req, res, next) {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
}
return next()
}
function limitWrites(req, res, next) {
if (writeRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
}
return next()
}
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
/** Eigenes Projekt pruefen (req.projectId, req.project); mit requireActive zusaetzlich die News-Funktion. */
const withProject = ({ requireActive = true } = {}) => async (req, res, next) => {
try {
const projectId = projectIdOf(req)
if (!projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
const customerId = getSessionCustomerId(req)
req.project = await assertOwnProject(customerId, projectId)
req.projectId = projectId
if (requireActive) {
const feature = await getFeatureDoc(customerId, projectId, 'news')
if (feature?.state !== 'active') {
return res.status(409).json({ error: 'News ist für diese Website nicht aktiv.' })
}
}
return next()
} catch (err) {
return fail(res, err)
}
}
router.get('/', requireSession, withProject(), async (req, res) => {
try {
const { hanging, expired } = await listPosts(req.projectId)
res.json({ hanging, expired, liveUrl: req.project.liveDomain ? `https://${req.project.liveDomain}` : '' })
} catch (err) {
fail(res, err)
}
})
router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
try {
const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body })
res.status(201).json(post)
} catch (err) {
fail(res, err)
}
})
// Foto als rohe Bytes (application/octet-stream), hoechstens 10 MB
router.post(
'/image',
requireSession,
rejectViewAs,
withProject(),
(req, res, next) => {
if (newsImageRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Fotos in kurzer Zeit. Bitte versuch es später wieder.' })
}
return next()
},
uploadSlot,
express.raw({ type: 'application/octet-stream', limit: NEWS_IMAGE_MAX_BYTES }),
async (req, res) => {
try {
const image = await storeNewsImage({ customerId: getSessionCustomerId(req), projectId: req.projectId, buffer: req.body })
res.status(201).json(image)
} catch (err) {
fail(res, err)
}
}
)
// Vorschau im Editor: nur fuer den Kunden, dem das Projekt gehoert
router.get('/image/:projectId/:file', requireSession, async (req, res) => {
try {
await assertOwnProject(getSessionCustomerId(req), req.params.projectId)
const match = IMAGE_FILE_RE.exec(req.params.file)
const file = match && newsImagePath(req.params.projectId, match[1], match[2])
if (!file) return res.status(404).json({ error: 'Foto nicht gefunden.' })
res.set({ 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' })
return res.sendFile(file, (err) => {
if (err && !res.headersSent) res.status(404).json({ error: 'Foto nicht gefunden.' })
})
} catch (err) {
return fail(res, err)
}
})
router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
try {
const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body })
res.json(post)
} catch (err) {
fail(res, err)
}
})
router.delete('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => {
try {
await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id })
res.status(204).end()
} catch (err) {
fail(res, err)
}
})
// Zu grosse Fotos verstaendlich melden
router.use((err, _req, res, next) => {
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Das Foto ist größer als 10 MB.' })
if (err?.type === 'request.aborted') return res.status(400).end()
return next(err)
})
export default router
+3
View File
@@ -26,6 +26,7 @@ import {
storeChatUpload, storeChatUpload,
uploadRateLimited, uploadRateLimited,
} from '../services/chatAttachments.js' } from '../services/chatAttachments.js'
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
import { offersAdminRouter } from './offers.js' import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js' import { storageTasksAdminRouter } from './storageTasks.js'
import { takeoverAdminRouter } from './takeoverCredits.js' import { takeoverAdminRouter } from './takeoverCredits.js'
@@ -40,6 +41,8 @@ router.use('/takeover-credits', takeoverAdminRouter)
router.use('/offers', offersAdminRouter) router.use('/offers', offersAdminRouter)
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher") // Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
router.use('/storage-tasks', storageTasksAdminRouter) router.use('/storage-tasks', storageTasksAdminRouter)
// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen")
router.use('/funktionen', funktionenAdminRouter)
function norm(value) { function norm(value) {
return String(value || '').toLowerCase() return String(value || '').toLowerCase()
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from 'node:crypto'
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { findActiveBySiteKey } from '../services/siteFeatures.js'
import { newsImagePath } from '../services/newsImages.js'
import { isHanging, publicPage } from '../services/newsPosts.js'
/**
* Oeffentliche Lese-API fuer die Pinnwand auf den Kundenwebsites: ohne Session, eigenes CORS (nur GET).
* Antworten enthalten nie Kunden-, Projekt-, Ticket- oder Stripe-IDs.
*/
const router = Router()
const WINDOW_MS = 60 * 1000
const MAX_PER_WINDOW = 120
// <Beitrags-ID>.webp oder <Beitrags-ID>-s.webp (die Bild-ID bleibt intern)
const IMAGE_FILE_RE = /^([A-Za-z0-9][A-Za-z0-9._-]*?)(-s)?\.webp$/
// Limit je IP und Minute (im Speicher); die App steht hinter Traefik, 'trust proxy' ist gesetzt -> req.ip ist die Client-IP
const hits = new Map()
let lastPrune = 0
function rateLimited(ip) {
const now = Date.now()
// Aufraeumen hoechstens einmal je Fenster
if (now - lastPrune >= WINDOW_MS) {
lastPrune = now
for (const [key, entry] of hits) if (now - entry.start >= WINDOW_MS) hits.delete(key)
}
let entry = hits.get(ip)
if (entry && now - entry.start >= WINDOW_MS) entry = null
if (!entry) {
entry = { start: now, count: 0 }
hits.set(ip, entry)
}
entry.count += 1
return entry.count > MAX_PER_WINDOW
}
router.use((req, res, next) => {
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, If-None-Match',
'Access-Control-Max-Age': '86400',
})
if (req.method === 'OPTIONS') return res.sendStatus(204)
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'Methode nicht erlaubt' })
if (rateLimited(req.ip)) {
res.set('Retry-After', '60')
return res.status(429).json({ error: 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' })
}
return next()
})
const EMPTY = { posts: [], hasMore: false }
function sendJsonWithEtag(req, res, body) {
const json = JSON.stringify(body)
const etag = `W/"${createHash('sha1').update(json).digest('base64url')}"`
res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60' })
const sent = String(req.get('If-None-Match') || '').split(',').map((s) => s.trim().replace(/^W\//, ''))
if (sent.includes(etag.slice(2)) || sent.includes('*')) return res.status(304).end()
return res.type('application/json').send(json)
}
router.get('/:siteKey', async (req, res) => {
try {
const feature = await findActiveBySiteKey(req.params.siteKey)
if (!feature) return sendJsonWithEtag(req, res, EMPTY)
const { posts, hasMore } = await publicPage(feature.projectId, req.query.page)
const base = `/api/public/news/${req.params.siteKey}/img`
return sendJsonWithEtag(req, res, {
posts: posts.map((p) => ({
...p,
image: p.image ? { w: p.image.w, h: p.image.h, src: `${base}/${p.id}.webp?v=${p.image.v}`, srcSmall: `${base}/${p.id}-s.webp?v=${p.image.v}` } : null,
})),
hasMore,
})
} catch (err) {
console.error('[publicNews]', err.message)
return res.status(503).json({ error: 'Vorübergehend nicht erreichbar.' })
}
})
router.get('/:siteKey/img/:file', async (req, res) => {
try {
const match = IMAGE_FILE_RE.exec(req.params.file)
if (!match) return res.status(404).end()
const [, postId, small] = match
const feature = await findActiveBySiteKey(req.params.siteKey)
if (!feature) return res.status(404).end()
let post = null
try {
post = await getDocument(config.collections.newsPosts, postId)
} catch (err) {
if (err.status !== 404 && err.status !== 400) throw err
}
if (!post || post.projectId !== feature.projectId || !post.imageId || !isHanging(post, new Date())) return res.status(404).end()
const file = newsImagePath(feature.projectId, post.imageId, small ? 's' : 'l')
if (!file) return res.status(404).end()
res.set({ 'Cache-Control': 'public, max-age=86400', 'X-Content-Type-Options': 'nosniff' })
return res.sendFile(file, (err) => {
if (err && !res.headersSent) res.status(404).end()
})
} catch (err) {
console.error('[publicNews]', err.message)
return res.status(503).json({ error: 'Vorübergehend nicht erreichbar.' })
}
})
export default router
+2 -2
View File
@@ -20,7 +20,7 @@ const wallFormat = new Intl.DateTimeFormat('en-US', {
}) })
// Kalendertag (YYYY-MM-DD) in Deutschland // Kalendertag (YYYY-MM-DD) in Deutschland
const berlinDay = (date) => dayFormat.format(new Date(date)) export const berlinDay = (date) => dayFormat.format(new Date(date))
// Reine Kalendertage rechnen wir als UTC-Mitternacht, damit Sommer-/Winterzeit keine Rolle spielt // Reine Kalendertage rechnen wir als UTC-Mitternacht, damit Sommer-/Winterzeit keine Rolle spielt
const dayToDate = (ymd) => new Date(`${ymd}T00:00:00Z`) const dayToDate = (ymd) => new Date(`${ymd}T00:00:00Z`)
@@ -88,7 +88,7 @@ function berlinOffset(t) {
} }
/** Zeitpunkt "Tag ymd, Uhrzeit in Deutschland" als Date (die Zeitumstellung liegt nie auf den genutzten Uhrzeiten). */ /** Zeitpunkt "Tag ymd, Uhrzeit in Deutschland" als Date (die Zeitumstellung liegt nie auf den genutzten Uhrzeiten). */
function berlinTime(ymd, hour, minute = 0, second = 0, ms = 0) { export function berlinTime(ymd, hour, minute = 0, second = 0, ms = 0) {
const [y, mo, d] = ymd.split('-').map(Number) const [y, mo, d] = ymd.split('-').map(Number)
const wall = Date.UTC(y, mo - 1, d, hour, minute, second, ms) const wall = Date.UTC(y, mo - 1, d, hour, minute, second, ms)
const guess = wall - berlinOffset(wall) const guess = wall - berlinOffset(wall)
+151
View File
@@ -0,0 +1,151 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import sharp from 'sharp'
import { config } from '../config.js'
/**
* News-Fotos: Kundenfotos fuer die Pinnwand. Jedes Bild wird neu kodiert (WebP, laengste Kante
* 1600 px, dazu Vorschau 480 px); dabei fallen alle Metadaten (GPS, Kamera, Drehung) weg.
*
* Ablage: <uploadDir>/<projectId>/<imageId>.webp, <imageId>-s.webp und Sidecar <imageId>.json.
* Hochgeladene, nie an einen Beitrag gebundene Bilder loescht purgeUnclaimedNewsImages() nach 24 h.
*/
export const NEWS_IMAGE_MAX_BYTES = 10 * 1024 * 1024
const UNCLAIMED_TTL_MS = 24 * 60 * 60 * 1000
const MAX_UPLOADS_PER_HOUR = 20
const SAFE_ID_RE = /^[a-z0-9-]+$/
// Hoechstens 40 Megapixel je Bild: ein winziges PNG/WebP kann sonst Gigabytes beim Dekodieren belegen
const MAX_INPUT_PIXELS = 40_000_000
// Fehlertexte von sharp/libvips bei kaputten, unbekannten oder zu grossen Bildern
const DECODE_ERROR_RE = /unsupported image format|pixel limit|corrupt|truncated|invalid|bad seek|bad (?:header|format)|not a|Input (?:buffer|file|Buffer)|Vips|heif|jpeg|png|webp|premature end|decode/i
const ACCEPTED_FORMATS = new Set(['jpeg', 'png', 'webp', 'heif'])
function httpError(status, message) {
return Object.assign(new Error(message), { status })
}
const safeId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 64 && SAFE_ID_RE.test(id)
const root = () => config.news.uploadDir
const projectDir = (projectId) => path.join(root(), projectId)
/** Pfad zu einer Bilddatei ('l' gross, 's' Vorschau) oder null bei unsauberen IDs. */
export function newsImagePath(projectId, imageId, size) {
if (!safeId(projectId) || !safeId(imageId)) return null
if (size !== 'l' && size !== 's') return null
return path.join(projectDir(projectId), size === 's' ? `${imageId}-s.webp` : `${imageId}.webp`)
}
const sidecarPath = (projectId, imageId) => path.join(projectDir(projectId), `${imageId}.json`)
export async function storeNewsImage({ customerId, projectId, buffer }) {
if (!safeId(projectId)) throw httpError(400, 'Ungültiges Projekt.')
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw httpError(415, 'Bitte lade ein Foto hoch (JPEG, PNG, WebP oder HEIC).')
if (buffer.length > NEWS_IMAGE_MAX_BYTES) throw httpError(413, 'Das Foto ist größer als 10 MB.')
const unsupported = () => httpError(415, 'Dieses Dateiformat können wir nicht verwenden. Bitte lade ein JPEG, PNG oder WebP hoch.')
let large, small
try {
const opts = { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }
const meta = await sharp(buffer, opts).metadata()
if (!ACCEPTED_FORMATS.has(meta.format)) throw unsupported()
// rotate() wendet die EXIF-Drehung an; ohne withMetadata() schreibt sharp keine Metadaten
large = await sharp(buffer, opts).rotate()
.resize({ width: 1600, height: 1600, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 }).toBuffer({ resolveWithObject: true })
// Vorschau aus dem schon verkleinerten Ergebnis (spart eine Dekodierung des Originals)
small = await sharp(large.data)
.resize({ width: 480, height: 480, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 75 }).toBuffer()
} catch (err) {
if (err.status) throw err
if (DECODE_ERROR_RE.test(String(err && err.message))) throw unsupported()
console.error('[news] Foto-Verarbeitung fehlgeschlagen:', err)
throw err
}
const imageId = randomUUID()
const { width: w, height: h } = large.info
const dir = projectDir(projectId)
await mkdir(dir, { recursive: true })
await writeFile(newsImagePath(projectId, imageId, 'l'), large.data)
await writeFile(newsImagePath(projectId, imageId, 's'), small)
// Sidecar zuletzt: ohne ihn gilt das Bild als nicht vorhanden
await writeFile(sidecarPath(projectId, imageId), JSON.stringify({ customerId, projectId, createdAt: new Date().toISOString(), claimed: false, w, h }))
return { imageId, w, h }
}
async function readSidecar(projectId, imageId) {
try {
return JSON.parse(await readFile(sidecarPath(projectId, imageId), 'utf8'))
} catch {
return null
}
}
/** Breite und Hoehe eines Bildes des Projekts, null wenn es nicht existiert. */
export async function readNewsImageMeta(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return null
const meta = await readSidecar(projectId, imageId)
return meta ? { w: meta.w, h: meta.h } : null
}
/** Markiert ein Bild als an einen Beitrag gebunden (wird nicht mehr aufgeraeumt). */
export async function claimNewsImage(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return
const meta = await readSidecar(projectId, imageId)
if (!meta || meta.claimed) return
meta.claimed = true
const file = sidecarPath(projectId, imageId)
await writeFile(`${file}.tmp`, JSON.stringify(meta))
await rename(`${file}.tmp`, file)
}
export async function deleteNewsImage(projectId, imageId) {
if (!safeId(projectId) || !safeId(imageId)) return
await Promise.all([
rm(newsImagePath(projectId, imageId, 'l'), { force: true }),
rm(newsImagePath(projectId, imageId, 's'), { force: true }),
rm(sidecarPath(projectId, imageId), { force: true }),
])
}
/** Loescht nicht beanspruchte Bilder, die aelter als 24 h sind. Gibt die Anzahl zurueck. */
export async function purgeUnclaimedNewsImages(now = new Date()) {
const nowMs = now instanceof Date ? now.getTime() : Number(now)
const projects = await readdir(root()).catch(() => [])
let removed = 0
for (const projectId of projects) {
if (!safeId(projectId)) continue
const files = await readdir(projectDir(projectId)).catch(() => [])
for (const f of files) {
if (!f.endsWith('.json')) continue
const imageId = f.slice(0, -5)
if (!safeId(imageId)) continue
const meta = await readSidecar(projectId, imageId)
if (!meta || meta.claimed) continue
const created = Date.parse(meta.createdAt)
if (Number.isFinite(created) && nowMs - created <= UNCLAIMED_TTL_MS) continue
await deleteNewsImage(projectId, imageId)
removed++
}
}
return removed
}
// Missbrauchsschutz: Uploads je Kunde und Stunde (im Speicher, reicht fuer einen Prozess)
const recentUploads = new Map()
export function newsImageRateLimited(customerId) {
const now = Date.now()
// Abgelaufene Eintraege aller Kunden entfernen, damit die Map nicht waechst
for (const [key, times] of recentUploads) {
const live = times.filter((t) => now - t < 60 * 60 * 1000)
if (live.length === 0) recentUploads.delete(key)
else if (live.length !== times.length) recentUploads.set(key, live)
}
const list = recentUploads.get(customerId) || []
list.push(now)
recentUploads.set(customerId, list)
return list.length > MAX_UPLOADS_PER_HOUR
}
+200
View File
@@ -0,0 +1,200 @@
import { config } from '../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { berlinDay, berlinTime } from './deadlines.js'
import { claimNewsImage, deleteNewsImage, readNewsImageMeta } from './newsImages.js'
import { addMonths } from './stripeBilling.js'
/**
* News-Beitraege der Pinnwand: Zettel (Notiz, Foto oder beides) mit Anzeigedauer.
* Zeiten stehen als ISO-Strings im Dokument; "hängend" = nicht abgenommen, sichtbar ab, nicht abgelaufen.
*/
export const DURATIONS = ['1w', '2w', '1m', '3m', 'open', 'date']
export const PAPERS = ['sun', 'sky', 'coral', 'white']
export const TYPES = ['note', 'photo', 'combo']
export const TITLE_MAX = 60
export const TEXT_MAX = 400
export const PAGE_SIZE = 6
const DAY_MS = 24 * 60 * 60 * 1000
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
function httpError(status, message, field) {
const err = Object.assign(new Error(message), { status })
if (field) err.field = field
return err
}
const plusCalendarDays = (ymd, n) => {
const [y, m, d] = ymd.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10)
}
/**
* Ende der Anzeige. `from` ist der Beginn, `now` der Bezugspunkt fuer die Datumsgrenzen (Standard: from).
* `date`: 23:59:59.999 Europe/Berlin, fruehestens morgen, hoechstens heute + 1 Jahr.
*/
export function visibleUntilFor(duration, from, dateIso, now = from) {
switch (duration) {
case '1w': return new Date(from.getTime() + 7 * DAY_MS)
case '2w': return new Date(from.getTime() + 14 * DAY_MS)
case '1m': return addMonths(from, 1)
case '3m': return addMonths(from, 3)
case 'open': return null
case 'date': {
if (typeof dateIso !== 'string' || !DATE_RE.test(dateIso) || Number.isNaN(Date.parse(dateIso))
|| new Date(`${dateIso}T00:00:00Z`).toISOString().slice(0, 10) !== dateIso) {
throw httpError(400, 'Bitte wähle ein gültiges Datum.', 'date')
}
const today = berlinDay(now)
const latest = addMonths(new Date(`${today}T00:00:00Z`), 12).toISOString().slice(0, 10)
if (dateIso < plusCalendarDays(today, 1)) throw httpError(400, 'Das Datum muss mindestens morgen sein.', 'date')
if (dateIso > latest) throw httpError(400, 'Das Datum darf höchstens ein Jahr in der Zukunft liegen.', 'date')
return berlinTime(dateIso, 23, 59, 59, 999)
}
default:
throw httpError(400, 'Bitte wähle eine Anzeigedauer.', 'duration')
}
}
export function isHanging(post, now) {
if (post.takenDownAt) return false
const t = now.getTime()
if (post.visibleFrom && Date.parse(post.visibleFrom) > t) return false
if (post.visibleUntil && t >= Date.parse(post.visibleUntil)) return false
return true
}
export function validatePostInput(input) {
const src = input && typeof input === 'object' ? input : {}
const str = (v) => (typeof v === 'string' ? v.trim() : '')
const type = src.type
if (!TYPES.includes(type)) throw httpError(400, 'Bitte wähle eine Art von Beitrag.', 'type')
// Der Titel ist optional (leer gespeichert)
const title = str(src.title)
if (title.length > TITLE_MAX) throw httpError(400, `Der Titel darf höchstens ${TITLE_MAX} Zeichen lang sein.`, 'title')
const text = str(src.text)
if (text.length > TEXT_MAX) throw httpError(400, `Der Text darf höchstens ${TEXT_MAX} Zeichen lang sein.`, 'text')
if ((type === 'note' || type === 'combo') && !text) throw httpError(400, 'Bitte gib einen Text ein.', 'text')
const imageId = str(src.imageId)
if ((type === 'photo' || type === 'combo') && !imageId) throw httpError(400, 'Bitte lade ein Foto hoch.', 'imageId')
if (!PAPERS.includes(src.paper)) throw httpError(400, 'Bitte wähle eine Zettelfarbe.', 'paper')
if (!DURATIONS.includes(src.duration)) throw httpError(400, 'Bitte wähle eine Anzeigedauer.', 'duration')
const out = {
type, title,
text, // bei reinem Foto ist ein Text optional
paper: src.paper, duration: src.duration,
}
if (src.duration === 'date') out.date = src.date
if (type !== 'note') out.imageId = imageId
return out
}
const byNewest = (a, b) =>
Date.parse(b.visibleFrom) - Date.parse(a.visibleFrom) || Date.parse(b.createdAt) - Date.parse(a.createdAt)
async function projectPosts(projectId) {
return listDocuments(config.collections.newsPosts, [Query.equal('projectId', projectId), Query.limit(1000)])
}
export async function listPosts(projectId, now = new Date()) {
const posts = (await projectPosts(projectId)).sort(byNewest)
return {
hanging: posts.filter((p) => isHanging(p, now)),
expired: posts.filter((p) => !isHanging(p, now)),
}
}
async function requireImage(projectId, imageId) {
if (!(await readNewsImageMeta(projectId, imageId))) throw httpError(400, 'Das Foto wurde nicht gefunden. Bitte lade es erneut hoch.', 'imageId')
}
export async function createPost({ customerId, projectId, input, now = new Date() }) {
const v = validatePostInput(input)
const until = visibleUntilFor(v.duration, now, v.date, now)
if (v.imageId) {
await requireImage(projectId, v.imageId)
await claimNewsImage(projectId, v.imageId)
}
const iso = now.toISOString()
const doc = await createDocument(config.collections.newsPosts, {
customerId, projectId,
type: v.type, title: v.title, text: v.text,
imageId: v.imageId || null,
paper: v.paper, duration: v.duration,
visibleFrom: iso, visibleUntil: until ? until.toISOString() : null,
takenDownAt: null, createdAt: iso, updatedAt: iso,
})
return doc
}
async function ownPost(customerId, projectId, id) {
let post
try {
post = await getDocument(config.collections.newsPosts, id)
} catch (err) {
if (err.status === 404) throw httpError(404, 'Beitrag nicht gefunden.')
throw err
}
if (post.customerId !== customerId || post.projectId !== projectId) throw httpError(403, 'Kein Zugriff auf diesen Beitrag.')
return post
}
export async function updatePost({ customerId, projectId, id, input, now = new Date() }) {
const post = await ownPost(customerId, projectId, id)
const iso = now.toISOString()
const action = input && input.action
let patch
let oldImageId = null
if (action === 'takeDown') {
patch = { takenDownAt: iso }
} else if (action === 'rehang') {
const duration = input.duration
const until = visibleUntilFor(duration, now, input.date, now)
patch = { duration, visibleFrom: iso, visibleUntil: until ? until.toISOString() : null, takenDownAt: null }
} else {
const v = validatePostInput(input)
// Relative Dauern zaehlen ab dem urspruenglichen Beginn, damit ein Bearbeiten nichts verlaengert
const from = new Date(post.visibleFrom)
// Unverändertes Datum bleibt speicherbar, auch wenn es inzwischen unter "morgen" liegt
const sameDate = v.duration === 'date' && post.duration === 'date' && DATE_RE.test(v.date || '')
&& post.visibleUntil === berlinTime(v.date, 23, 59, 59, 999).toISOString()
const until = sameDate ? new Date(post.visibleUntil) : visibleUntilFor(v.duration, from, v.date, now)
const imageId = v.imageId || null
if (imageId && imageId !== post.imageId) {
await requireImage(projectId, imageId)
const used = (await projectPosts(projectId)).some((p) => p.$id !== id && p.imageId === imageId)
if (used) throw httpError(400, 'Dieses Foto gehört schon zu einem anderen Beitrag.', 'imageId')
}
patch = {
type: v.type, title: v.title, text: v.text, paper: v.paper, duration: v.duration,
visibleUntil: until ? until.toISOString() : null, imageId,
}
if (post.imageId && post.imageId !== imageId) oldImageId = post.imageId
if (imageId && imageId !== post.imageId) await claimNewsImage(projectId, imageId)
}
const doc = await updateDocument(config.collections.newsPosts, id, { ...patch, updatedAt: iso })
if (oldImageId) await deleteNewsImage(projectId, oldImageId)
return doc
}
export async function deletePost({ customerId, projectId, id }) {
const post = await ownPost(customerId, projectId, id)
await deleteDocument(config.collections.newsPosts, id)
if (post.imageId) await deleteNewsImage(projectId, post.imageId)
}
/** Oeffentliche Seite der Website: nur haengende Beitraege, ohne interne Felder, 6 je Seite. */
export async function publicPage(projectId, page, now = new Date()) {
const n = Math.max(1, Math.floor(Number(page)) || 1)
const { hanging } = await listPosts(projectId, now)
const slice = hanging.slice((n - 1) * PAGE_SIZE, n * PAGE_SIZE)
const posts = []
for (const p of slice) {
const meta = p.imageId ? await readNewsImageMeta(projectId, p.imageId) : null
// v (Zeitstempel der letzten Aenderung) dient der Route als Cache-Buster fuer die Bild-URL
const image = meta ? { ...meta, v: Date.parse(p.updatedAt || p.visibleFrom) } : null
posts.push({ id: p.$id, type: p.type, title: p.title, text: p.text, paper: p.paper, date: berlinDay(p.visibleFrom), image })
}
return { posts, hasMore: hanging.length > n * PAGE_SIZE }
}
+51 -4
View File
@@ -2,6 +2,8 @@ import { rm } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { config } from '../config.js' import { config } from '../config.js'
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js' import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
import { deleteNewsImage } from './newsImages.js'
import { disable } from './siteFeatures.js'
import { getStripe } from './stripeBilling.js' import { getStripe } from './stripeBilling.js'
import { filesOf, STATUS } from './takeoverCredits.js' import { filesOf, STATUS } from './takeoverCredits.js'
@@ -16,6 +18,9 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die * (purgeExpiredUploads, sechs Monate nach der Entscheidung); hier werden sie nur gemeldet, falls die
* Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des * Bereinigung sie verpasst hat. Genehmigte Antraege sind Belege und bleiben acht Jahre ab Ende des
* Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht. * Entscheidungsjahres (Abschnitt 9, § 147 AO); erst danach werden sie samt Nachweisen geloescht.
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr. * Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden * Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine * weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
@@ -25,6 +30,8 @@ import { filesOf, STATUS } from './takeoverCredits.js'
const SUBSCRIPTIONS = () => config.collections.subscriptions const SUBSCRIPTIONS = () => config.collections.subscriptions
const EVENTS = () => config.collections.contractEvents const EVENTS = () => config.collections.contractEvents
const TAKEOVER = () => config.collections.takeoverCredits const TAKEOVER = () => config.collections.takeoverCredits
const NEWS_POSTS = () => config.collections.newsPosts
const FEATURES = () => config.collections.portalFeatures
export const RETENTION_YEARS = 3 export const RETENTION_YEARS = 3
export const RECEIPT_YEARS = 8 export const RECEIPT_YEARS = 8
@@ -101,6 +108,18 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) }) customers.push({ customerId, lastEndAt, subscriptionIds: docs.map((d) => d.$id), eventIds: events.map((e) => e.$id) })
} }
const news = []
for (const { customerId } of customers) {
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
if (!posts.length && !features.length) continue
news.push({
customerId,
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
featureIds: features.map((f) => f.$id),
})
}
const takeover = [] const takeover = []
const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000 const rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)]) const rejected = await list(TAKEOVER(), [Query.equal('status', STATUS.rejected)])
@@ -126,7 +145,7 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`, reason: `genehmigt ${decided.slice(0, 10)}, Beleg-Aufbewahrung von acht Jahren ab Jahresende abgelaufen`,
}) })
} }
return { customers, takeover, pending, skipped } return { customers, news, takeover, pending, skipped }
} }
const isGone = (err) => err?.status === 404 const isGone = (err) => err?.status === 404
@@ -145,12 +164,13 @@ async function deleteQuietly(collection, id) {
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die * Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual; * Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen. * die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number }, * @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
* newsPosts: number, newsImages: number, features: number },
* manual: { customerId: string, subscriptionId: string, reason: string }[], * manual: { customerId: string, subscriptionId: string, reason: string }[],
* failed: { what: string, id: string, reason: string }[] }>} * failed: { what: string, id: string, reason: string }[] }>}
*/ */
export async function applyDeletions(plan) { export async function applyDeletions(plan) {
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0 } const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0 }
const manual = [] const manual = []
const failed = [] const failed = []
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, ''])) const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
@@ -184,6 +204,28 @@ export async function applyDeletions(plan) {
} }
} }
for (const entry of plan.news || []) {
for (const post of entry.posts) {
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
if (post.imageId) {
const removed = await attempt('newsImages', post.imageId, async () => {
await deleteNewsImage(post.projectId, post.imageId)
return true
})
if (!removed) continue
deleted.newsImages += 1
}
if (await attempt('newsPosts', post.id, () => deleteQuietly(NEWS_POSTS(), post.id))) deleted.newsPosts += 1
}
for (const id of entry.featureIds) {
const off = await attempt('portalFeatures', id, async () => {
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
return true
})
if (off) deleted.features += 1
}
}
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) { for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
const removed = await attempt('takeoverCredits', item.id, async () => { const removed = await attempt('takeoverCredits', item.id, async () => {
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true }) await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
@@ -206,6 +248,10 @@ export function formatReport(plan, result = null) {
`Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)` `Kunde ${c.customerId}: letztes Abo endete am ${fmtDate(c.lastEndAt)}; ${c.subscriptionIds.length} Abo(s) (${c.subscriptionIds.join(', ')}), ${c.eventIds.length} Vertragsereignis(se)`
) )
} }
for (const n of plan.news || []) {
const images = n.posts.filter((p) => p.imageId).length
lines.push(`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten`)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen') const del = plan.takeover.filter((t) => t.action === 'loeschen')
const report = plan.takeover.filter((t) => t.action === 'melden') const report = plan.takeover.filter((t) => t.action === 'melden')
if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`)) if (del.length) lines.push('', 'Übernahme-Anträge zu löschen:', ...del.map((t) => ` ${t.id}: ${t.reason}`))
@@ -220,7 +266,8 @@ export function formatReport(plan, result = null) {
const d = result.deleted const d = result.deleted
lines.push( lines.push(
'', '',
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).` `Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
) )
if (result.failed?.length) { if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`)) lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
+544
View File
@@ -0,0 +1,544 @@
import { createHash, randomBytes } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
export const DISABLED_MESSAGE =
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** 16 Zeichen base32 (a-z, 2-7) aus 80 zufaelligen Bits. */
export function newSiteKey() {
const bytes = randomBytes(10)
let bits = 0n
for (const b of bytes) bits = (bits << 8n) | BigInt(b)
let out = ''
for (let i = 15; i >= 0; i -= 1) out += BASE32[Number((bits >> BigInt(i * 5)) & 31n)]
return out
}
/**
* Feste Dokument-ID je (Projekt, Funktion): macht (Kunde, Projekt, key) eindeutig. Passt sie nicht in
* Appwrites 36 Zeichen, wird sie per sha1 gekuerzt (wie eventIdFor).
*/
function featureDocId(projectId, key) {
const plain = `f_${projectId}_${key}`
if (plain.length <= 36 && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(plain)) return plain
return `f_${createHash('sha1').update(`${projectId}_${key}`).digest('hex').slice(0, 34)}`
}
function assertKey(key) {
if (!FEATURE_KEYS.includes(key)) throw httpError(400, 'Unbekannte Funktion')
}
/** state und enabled werden immer zusammen geschrieben. */
function stateFields(state) {
return { state, enabled: state === 'active' }
}
export async function getFeatureDoc(customerId, projectId, key) {
try {
const doc = await getDocument(config.collections.portalFeatures, featureDocId(projectId, key))
return doc.customerId === customerId ? doc : null
} catch (err) {
if (err.status === 404) return null
throw err
}
}
export async function assertOwnProject(customerId, projectId) {
let project = null
try {
project = await getDocument(config.collections.websiteProjects, projectId)
} catch (err) {
if (err.status !== 404) throw err
}
if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt')
return project
}
/** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */
async function ensureFeatureDoc(customerId, projectId, key) {
const existing = await getFeatureDoc(customerId, projectId, key)
if (existing) return existing
try {
return await createDocument(
config.collections.portalFeatures,
{
customerId,
projectId,
featureKey: key,
...stateFields('off'),
unlockedByPurchase: false,
purchaseStatus: '',
siteKey: newSiteKey(),
},
featureDocId(projectId, key)
)
} catch (err) {
if (err.status === 409) {
const again = await getFeatureDoc(customerId, projectId, key)
if (again) return again
}
throw err
}
}
async function ticketWoid(ticketId) {
if (!ticketId) return null
try {
const ticket = await getDocument(config.collections.workorders, ticketId)
return ticket.woid || null
} catch (err) {
if (err.status === 404) return null
throw err
}
}
async function featureView(doc) {
return {
state: doc?.state || 'off',
siteKey: doc?.siteKey || null,
ticketWoid: doc?.ticketId ? await ticketWoid(doc.ticketId) : null,
orderedAt: doc?.orderedAt || null,
activatedAt: doc?.activatedAt || null,
invoiceUrl: doc?.invoiceUrl || null,
}
}
export async function listCustomerFeatures(customerId) {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
const result = []
for (const p of projects) {
const features = {}
for (const key of FEATURE_KEYS) {
const doc = docs.find((d) => d.projectId === p.$id && d.featureKey === key)
features[key] = await featureView(doc)
}
result.push({
projectId: p.$id,
projectName: p.projectName || '',
liveUrl: p.liveDomain ? `https://${p.liveDomain}` : '',
features,
})
}
return result
}
/**
* Rechnungslink nachtragen: Stripe legt die Rechnung eines Zahlungs-Checkouts erst kurz nach der Zahlung an,
* der Webhook sieht sie oft noch nicht. Best effort - Fehler werden geloggt und nie weitergereicht.
*/
export async function backfillInvoiceLinks(customerId) {
try {
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
for (const doc of docs) {
if ((doc.state !== 'setup' && doc.state !== 'active') || doc.invoiceUrl || !doc.stripeSessionId) continue
try {
const session = await retrieveCheckoutSession(doc.stripeSessionId, { expandInvoice: true })
const invoice = session.invoice && typeof session.invoice === 'object' ? session.invoice : null
if (!invoice?.hosted_invoice_url) continue
await updateDocument(config.collections.portalFeatures, doc.$id, {
stripeInvoiceId: invoice.id || doc.stripeInvoiceId || '',
invoiceUrl: invoice.hosted_invoice_url,
})
} catch (err) {
console.warn(`[funktionen] Rechnungslink fuer Sitzung ${doc.stripeSessionId} nicht nachgetragen:`, err.message)
}
}
} catch (err) {
console.warn('[funktionen] Rechnungslinks nicht nachgetragen:', err.message)
}
}
/**
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht.
*/
export async function paidFeatureOrders(customerId) {
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
const paid = docs.filter((d) => ['setup', 'active', 'disabled'].includes(d.state) && d.unlockedByPurchase)
const result = []
for (const doc of paid) {
result.push({
projekt: await projectName(doc.projectId).catch(() => ''),
state: doc.state,
orderedAt: doc.orderedAt || '',
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
})
}
return result
}
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
assertKey(key)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (doc.state === 'setup' || doc.state === 'active') {
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet')
}
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
return updateDocument(config.collections.portalFeatures, doc.$id, {
featureKey: key,
...stateFields('ordered'),
purchaseStatus: 'ordered',
stripeSessionId: sessionId,
orderedAt: new Date().toISOString(),
consentText: String(consent.text || '').slice(0, 1000),
consentAt: consent.at || null,
consentIp: String(consent.ip || '').slice(0, 64),
consentUserAgent: String(consent.userAgent || '').slice(0, 300),
})
}
async function findBySession(sessionId) {
if (!sessionId) return null
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('stripeSessionId', sessionId),
Query.limit(1),
])
return doc || null
}
/**
* Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst
* wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId
* (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook:
* 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus).
*/
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
if (!sessionId) return { doc: null, created: false }
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc) return { doc: null, created: false }
const resumable = doc.state === 'setup' && !doc.ticketId
if (doc.state !== 'ordered' && !resumable) return { doc, created: false }
const setup = resumable
? doc
: await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: doc.featureKey,
siteKey: doc.siteKey,
})
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
return { doc: updated, created: true }
})
}
/**
* Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
* Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
* Bestellung ('setup') oder eine aktive Funktion anfasst.
*/
export async function markCheckoutFailed(sessionId) {
if (!sessionId) return null
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc || doc.state !== 'ordered') return doc
return updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('off'),
purchaseStatus: 'failed',
})
})
}
function byNote(by, verb) {
return by ? `${verb} von ${by}`.slice(0, 300) : undefined
}
export async function activate(docId, { by, adminNote } = {}) {
const note = adminNote ?? byNote(by, 'Freigeschaltet')
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('active'),
activatedAt: new Date().toISOString(),
...(note ? { adminNote: note } : {}),
})
}
export async function disable(docId, { by, adminNote } = {}) {
const note = adminNote ?? byNote(by, 'Abgeschaltet')
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('disabled'),
disabledAt: new Date().toISOString(),
...(note ? { adminNote: note } : {}),
})
}
/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
assertKey(key)
await assertOwnProject(customerId, projectId)
const doc = await ensureFeatureDoc(customerId, projectId, key)
return activate(doc.$id, { by, adminNote })
}
/** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */
export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) {
const queries = [Query.equal('state', 'setup'), Query.limit(1000)]
if (customerId) queries.push(Query.equal('customerId', customerId))
const pending = await listDocuments(config.collections.portalFeatures, queries)
const activated = []
for (const doc of pending) {
if (!doc.ticketId) continue
let ticket = null
try {
ticket = await getDocument(config.collections.workorders, doc.ticketId)
} catch (err) {
if (err.status === 404) continue
throw err
}
if (ticket.status !== 'Closed') continue
// Je Dokument gesperrt und neu gelesen: GET /api/funktionen, ein zweiter Tab und der 5-Minuten-Job
// duerfen dieselbe Funktion nicht beide freischalten (und beide benachrichtigen)
const updated = await withLocks([`feature-activate:${doc.$id}`], async () => {
const fresh = await getDocument(config.collections.portalFeatures, doc.$id)
if (fresh.state !== 'setup') return null
return updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('active'),
activatedAt: now.toISOString(),
})
})
if (updated) activated.push(updated)
}
return activated
}
export async function findActiveBySiteKey(siteKey) {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('state', 'active'),
Query.limit(1),
])
return doc || null
}
// ---------------------------------------------------------------------------
// Stripe-Webhook, Mails und Freischalt-Job
// ---------------------------------------------------------------------------
const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news`
/** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */
export const FEATURE_EVENT_TYPES = [
'checkout.session.completed',
'checkout.session.async_payment_succeeded',
'checkout.session.async_payment_failed',
'checkout.session.expired',
]
export function isFeatureCheckoutEvent(event) {
const obj = event?.data?.object
return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type)
}
async function projectName(projectId) {
try {
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
} catch (err) {
if (err.status === 404) return ''
throw err
}
}
async function customerOf(customerId) {
try {
return await getDocument(config.collections.customers, customerId)
} catch (err) {
if (err.status === 404) return null
throw err
}
}
/** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */
async function invoiceOf(invoiceRef) {
const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id
if (!id) return { invoiceId: '', invoiceUrl: '' }
try {
const invoice = await getStripe().invoices.retrieve(id)
return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' }
} catch (err) {
console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message)
return { invoiceId: id, invoiceUrl: '' }
}
}
async function ticketWoidOf(doc) {
return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : ''
}
async function teamNotice(doc, customer, { failed = false } = {}) {
await sendAdminNotice({
template: 'T16',
values: {
kunde_name: customer?.name || customer?.email || doc.customerId,
email: customer?.email || '',
projekt: await projectName(doc.projectId),
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
sitzung: '',
},
blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false },
})
}
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
async function unmatchedNotice(session) {
const { customerId, projectId } = session.metadata || {}
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
await sendAdminNotice({
template: 'T16',
values: {
kunde_name: customer?.name || customer?.email || customerId || 'unbekannt',
email: customer?.email || '',
projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '',
woid: '',
sitekey: '',
sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`,
},
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: true },
})
}
/**
* Bezahlte Sitzung, die kein Dokument ueber die Sitzungs-ID findet (zwei parallele Bestellungen haben die
* ID ueberschrieben): das Dokument aus den Metadaten nehmen, wenn es noch 'ordered' oder 'off' ist, und die
* bezahlte Sitzung eintragen. Sonst laut loggen - der Kunde hat gezahlt und nichts wird eingerichtet.
*/
async function adoptPaidSession(session) {
const { customerId, projectId, featureKey } = session.metadata || {}
const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null
if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) {
console.error(
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`
)
try {
await unmatchedNotice(session)
} catch (err) {
console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err.message)
}
return false
}
await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('ordered'),
stripeSessionId: session.id,
})
return true
}
async function onFeaturePaid(session) {
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
let { doc, created } = await markPaid(paid)
// Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an
if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid))
if (!created) return
// Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und
// wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200.
let customer = null
try {
customer = await customerOf(doc.customerId)
} catch (err) {
console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message)
}
if (customer?.email) {
try {
await sendCustomerMail(customer.email, {
template: 'K15',
values: {
kunde_name: customer.name || '',
projekt: await projectName(doc.projectId),
preis: eur(config.newsSetup.amountCents),
zeit: config.funktionen.setupEstimate,
rechnung_url: invoiceUrl,
},
blocks: { rechnung: Boolean(invoiceUrl) },
})
} catch (err) {
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
}
}
try {
await teamNotice(doc, customer)
} catch (err) {
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
}
}
/** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */
export async function handleFeatureCheckoutEvent(event) {
const session = event.data.object
switch (event.type) {
case 'checkout.session.completed':
// SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded
if (session.payment_status !== 'paid') return
await onFeaturePaid(session)
return
case 'checkout.session.async_payment_succeeded':
await onFeaturePaid(session)
return
case 'checkout.session.async_payment_failed': {
const doc = await markCheckoutFailed(session.id)
if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true })
return
}
case 'checkout.session.expired':
await markCheckoutFailed(session.id)
return
}
}
/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */
export async function runActivation({ now = new Date(), customerId = null } = {}) {
const activated = await activateClosedTickets({ now, customerId })
for (const doc of activated) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) continue
await sendCustomerMail(customer.email, {
template: 'K16',
values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() },
})
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
}
}
return activated
}
+53 -4
View File
@@ -51,17 +51,19 @@ export function isStripeConfigured() {
} }
// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice) // Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice)
const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher' } const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung' }
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */ /** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */
function catalogFor(key) { function catalogFor(key) {
return key === 'mailStorage' ? config.mailStorage : getProduct(key) if (key === 'mailStorage') return config.mailStorage
if (key === 'newsSetup') return config.newsSetup
return getProduct(key)
} }
const priceIdCache = new Map() const priceIdCache = new Map()
/** /**
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
* 'mailStorage'. Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * 'mailStorage' | 'newsSetup' (Einmalpreis). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
*/ */
export async function ensurePrice(key) { export async function ensurePrice(key) {
@@ -94,7 +96,7 @@ export async function ensurePrice(key) {
product: product.id, product: product.id,
currency: 'eur', currency: 'eur',
unit_amount: catalog.amountCents, unit_amount: catalog.amountCents,
recurring: { interval: 'month' }, ...(catalog.oneTime ? {} : { recurring: { interval: 'month' } }),
tax_behavior: 'inclusive', tax_behavior: 'inclusive',
lookup_key: catalog.lookupKey, lookup_key: catalog.lookupKey,
nickname: catalog.priceNickname, nickname: catalog.priceNickname,
@@ -589,6 +591,53 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
return session.url return session.url
} }
/**
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
* @returns {Promise<{ id: string, url: string }>}
*/
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
const params = {
mode: 'payment',
customer: stripeCustomerId,
client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'sepa_debit'],
locale: 'de',
billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' },
invoice_creation: {
enabled: true,
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
},
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
}
let session
try {
session = await stripe.checkout.sessions.create(params)
} catch (err) {
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout)
if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
} else {
throw err
}
}
return { id: session.id, url: session.url }
}
/** Gespeicherte Funktions-Sitzung abrufen (status open|complete|expired); mit expand die Rechnung als Objekt. */
export async function retrieveCheckoutSession(id, { expandInvoice = false } = {}) {
return getStripe().checkout.sessions.retrieve(id, expandInvoice ? { expand: ['invoice'] } : {})
}
export async function createBillingPortalUrl(customerId) { export async function createBillingPortalUrl(customerId) {
const sub = await getCurrentSubscription(customerId) const sub = await getCurrentSubscription(customerId)
if (!sub?.stripeCustomerId) { if (!sub?.stripeCustomerId) {
+40
View File
@@ -65,3 +65,43 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) {
console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`) console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`)
return ticket.$id return ticket.$id
} }
/**
* Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite.
* Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei.
*/
export async function createFeatureTicket({ customerId, project, featureKey, siteKey }) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
const woid = await generateNextWoid()
const projectName = project?.projectName || project?.subdomain || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
const embed = `<div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`
const ticket = await createDocument(
config.collections.workorders,
{
topic: `News-Pinnwand einbauen – ${projectName}`.trim(),
type: 'Webpage',
status: 'Open',
priority: 1,
serviceType: 'Remote',
systemType: 'n/a',
customerId,
customerName: customer?.name || '',
customerLocation: customer?.location || '',
woid,
details: [
`Funktion: ${featureKey}`,
`Projekt: ${projectName}`,
`Domain: ${domain}`,
`siteKey: ${siteKey}`,
`Einbau: ${embed}`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n'),
createdAt: new Date().toISOString(),
},
ID.unique()
)
console.log(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) fuer Projekt ${project?.$id} angelegt`)
return { ticketId: ticket.$id, woid }
}
+397
View File
@@ -0,0 +1,397 @@
import { readFileSync } from 'node:fs'
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
const { adminCookie, customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/api/portal-admin': portalAdminRoutes,
'/api/public/news': publicNewsRoutes,
})
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Kiosk' })
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Bistro' })
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' })
const cookie = customerCookie('c1')
const checkout = (projectId, consent = true) => post('/api/funktionen/news/checkout', { projectId, consent }, { cookie })
const sessions = () => [...st.checkoutSessions.values()]
const workorders = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
const subjects = () => mails.sent.map((m) => m.subject)
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const paidSession = (id, projectId, extra = {}) => ({
id,
object: 'checkout.session',
mode: 'payment',
payment_status: 'paid',
metadata: { customerId: 'c1', projectId, featureKey: 'news' },
...extra,
})
test('liste_zustaende', async () => {
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
assert.equal(body.consentText, config.funktionen.consentText)
assert.equal(body.projects.length, 4)
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
})
test('checkout_ohne_zustimmung_400', async () => {
assert.equal((await checkout('p1', false)).status, 400)
assert.equal((await post('/api/funktionen/news/checkout', { projectId: 'p1' }, { cookie })).status, 400)
assert.equal(sessions().length, 0)
})
test('checkout_fremdes_projekt_403', async () => {
assert.equal((await checkout('p5')).status, 403)
assert.equal(sessions().length, 0)
})
test('checkout_legt_session_an', async () => {
const res = await checkout('p1')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\//)
const [session] = sessions()
assert.equal(session.mode, 'payment')
assert.equal(session.metadata.featureKey, 'news')
assert.equal(session.metadata.projectId, 'p1')
assert.equal(session.metadata.customerId, 'c1')
const call = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
assert.equal(call.body['invoice_creation[enabled]'], 'true')
assert.equal(call.body.success_url, `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`)
assert.equal(call.body.cancel_url, `${config.publicBaseUrl}/dashboard.html#news`)
assert.ok(Number(call.body.expires_at) > Date.now() / 1000)
const price = [...st.prices.values()].find((p) => p.id === call.body['line_items[0][price]'])
assert.equal(price.unit_amount, 3000)
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.equal(doc.state, 'ordered')
assert.equal(doc.stripeSessionId, session.id)
assert.equal(doc.consentText, config.funktionen.consentText)
assert.ok(doc.consentAt)
assert.ok(doc.consentUserAgent)
// zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung
const again = await checkout('p1')
assert.equal(again.status, 200)
assert.equal(again.body.url, res.body.url)
assert.equal(sessions().length, 1)
})
test('abgelaufene_sitzung_darf_neu_gestartet_werden', async () => {
sessions().at(-1).status = 'expired'
const res = await checkout('p1')
assert.equal(res.status, 200)
assert.equal(sessions().length, 2)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).stripeSessionId, sessions().at(-1).id)
})
test('abgeschlossene_sitzung_409', async () => {
sessions().at(-1).status = 'complete'
const before = sessions().length
assert.equal((await checkout('p1')).status, 409)
assert.equal(sessions().length, before)
})
test('checkout_bei_setup_409', async () => {
const sid = 'cs_setup'
await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: sid, consent: {} })
await sf.markPaid({ sessionId: sid })
const before = sessions().length
assert.equal((await checkout('p2')).status, 409)
assert.equal(sessions().length, before)
})
test('sepa_wartet_auf_zahlung', async () => {
const res = await checkout('p3')
assert.equal(res.status, 200)
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
const unpaid = paidSession(id, 'p3', { payment_status: 'unpaid' })
assert.equal((await postWebhook(event('checkout.session.completed', unpaid))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'ordered')
assert.equal(workorders().length, wosBefore)
assert.equal(mails.sent.length, mailsBefore)
assert.equal((await postWebhook(event('checkout.session.async_payment_succeeded', paidSession(id, 'p3')))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'setup')
assert.equal(workorders().length, wosBefore + 1)
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
const k15 = mails.sent.at(-2).subject.startsWith('Deine') ? mails.sent.at(-2) : mails.sent.at(-1)
assert.equal(k15.to[0].address, 'k@example.invalid')
assert.match(k15.text, /30,00 €/)
})
test('webhook_idempotent', async () => {
await checkout('p4')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
for (let i = 0; i < 2; i += 1) {
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'p4')))).status, 200)
}
assert.equal(workorders().length, wosBefore + 1)
assert.equal(mails.sent.length - mailsBefore, 2)
assert.equal(subjects().slice(mailsBefore).filter((s) => s.startsWith('Deine Bestellung')).length, 1)
})
test('abgelaufen_zurueck_auf_off', async () => {
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Werkstatt' })
await checkout('p6')
const id = sessions().at(-1).id
const expired = { id, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p6', featureKey: 'news' } }
assert.equal((await postWebhook(event('checkout.session.expired', expired))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p6', 'news')).state, 'off')
assert.equal((await checkout('p6')).status, 200)
})
test('zahlung_fehlgeschlagen_mailt_team', async () => {
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Garage' })
await checkout('p7')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
await postWebhook(event('checkout.session.async_payment_failed', paidSession(id, 'p7', { payment_status: 'unpaid' })))
assert.equal((await sf.getFeatureDoc('c1', 'p7', 'news')).state, 'off')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.match(mails.sent.at(-1).text, /Zahlung fehlgeschlagen/)
})
test('aktivierung_mailt', async () => {
const doc = await sf.getFeatureDoc('c1', 'p3', 'news')
assert.equal(doc.state, 'setup')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const mailsBefore = mails.sent.length
const done = await sf.runActivation()
assert.equal(done.length, 1)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'active')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.equal(mails.sent.at(-1).subject, 'Deine News-Pinnwand ist bereit')
assert.equal(mails.sent.at(-1).to[0].address, 'k@example.invalid')
assert.deepEqual(await sf.runActivation(), [])
assert.equal(mails.sent.length - mailsBefore, 1)
})
test('parallele_checkouts_eine_sitzung', async () => {
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' })
const before = sessions().length
const results = await Promise.all([checkout('p8'), checkout('p8')])
assert.deepEqual(results.map((r) => r.status), [200, 200])
assert.equal(results[0].body.url, results[1].body.url)
assert.equal(sessions().length, before + 1)
})
test('bezahlte_sitzung_mit_ueberschriebener_id_wird_zugeordnet', async () => {
aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Tischlerei' })
await checkout('p9')
const paidId = sessions().at(-1).id
const doc = await sf.getFeatureDoc('c1', 'p9', 'news')
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), stripeSessionId: 'cs_ueberschrieben' })
const wosBefore = workorders().length
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(paidId, 'p9')))).status, 200)
const after = await sf.getFeatureDoc('c1', 'p9', 'news')
assert.equal(after.state, 'setup')
assert.equal(after.stripeSessionId, paidId)
assert.equal(workorders().length, wosBefore + 1)
})
test('parallele_aktivierung_mailt_einmal', async () => {
aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Schreinerei' })
await checkout('p10')
const id = sessions().at(-1).id
await postWebhook(event('checkout.session.completed', paidSession(id, 'p10')))
const doc = await sf.getFeatureDoc('c1', 'p10', 'news')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const mailsBefore = mails.sent.length
const [a, b] = await Promise.all([sf.runActivation(), sf.runActivation({ customerId: 'c1' })])
assert.equal(a.length + b.length, 1)
assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1)
})
// ---------------------------------------------------------------- Admin ---
const adminC = adminCookie()
const adminPost = (customerId, body, cookie = adminC) => post(`/api/portal-admin/funktionen/${customerId}/news`, body, { cookie })
test('admin_aktivieren_ohne_zahlung', async () => {
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' })
const wosBefore = workorders().length
const res = await adminPost('c1', { projectId: 'pa1', action: 'activate', note: 'Kulanz' })
assert.equal(res.status, 200)
const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news
assert.equal(view.state, 'active')
assert.equal(view.ticketWoid, null)
assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Kenso: activate – Kulanz$/)
const doc = await sf.getFeatureDoc('c1', 'pa1', 'news')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(workorders().length, wosBefore)
const list = await get('/api/portal-admin/funktionen/c1', { cookie: adminC })
assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active')
})
test('admin_abschalten', async () => {
const doc = await sf.getFeatureDoc('c1', 'pa1', 'news')
const publicUrl = `/api/public/news/${doc.siteKey}`
assert.equal((await rawGet(publicUrl)).status, 200)
const res = await adminPost('c1', { projectId: 'pa1', action: 'disable' })
assert.equal(res.status, 200)
const view = res.body.projects[0].features.news
assert.equal(view.state, 'disabled')
assert.match(view.adminNote, /Kenso: disable \| .*Kenso: activate – Kulanz$/)
assert.ok(view.adminNote.length <= 300)
assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false)
const { status, body } = await rawGet(publicUrl)
assert.ok(status === 404 || (status === 200 && (body.posts || []).length === 0), `öffentlich: ${status}`)
})
test('admin_ungueltig_und_fremd', async () => {
aw.seed('websiteProjects', { $id: 'pa4', customerId: 'c1', projectName: 'Ohne Dokument' })
assert.equal((await adminPost('c1', { projectId: 'pa1', action: 'loeschen' })).status, 400)
assert.equal((await adminPost('c1', { projectId: 'p5', action: 'activate' })).status, 403)
assert.equal((await adminPost('c1', { projectId: 'gibtsnicht', action: 'activate' })).status, 403)
assert.equal((await adminPost('c1', { projectId: 'pa4', action: 'disable' })).status, 409)
})
test('admin_notiz_bleibt_im_limit', async () => {
aw.seed('websiteProjects', { $id: 'pa2', customerId: 'c1', projectName: 'Notiz' })
for (let i = 0; i < 8; i += 1) {
await adminPost('c1', { projectId: 'pa2', action: i % 2 ? 'disable' : 'activate', note: 'x'.repeat(100) })
}
const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote
assert.ok(note.length <= 300)
assert.match(note, /^\S+ Kenso: disable – x+/)
})
test('kunde_darf_admin_route_nicht', async () => {
aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' })
const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, customerCookie('c1'))
assert.ok(res.status === 401 || res.status === 403)
assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null)
const list = await rawGet('/api/portal-admin/funktionen/c1', { cookie: customerCookie('c1') })
assert.ok(list.status === 401 || list.status === 403)
})
test('ticket_fehlt_markiert', async () => {
aw.seed('websiteProjects', { $id: 'pa3', customerId: 'c1', projectName: 'Ticketlos' })
await checkout('pa3')
await postWebhook(event('checkout.session.completed', paidSession(sessions().at(-1).id, 'pa3')))
const doc = await sf.getFeatureDoc('c1', 'pa3', 'news')
assert.equal(doc.state, 'setup')
const newsOf = async () =>
(await get('/api/portal-admin/funktionen/c1', { cookie: adminC })).projects.find((p) => p.projectId === 'pa3').features.news
assert.equal((await newsOf()).ticketMissing, false)
await deleteDocument(config.collections.workorders, doc.ticketId)
const missing = await newsOf()
assert.equal(missing.ticketMissing, true)
assert.equal(missing.ticketWoid, null)
})
// ------------------------------------------------ Schlussdurchgang ---
test('webhook_retry_setzt_ticketlose_einrichtung_fort', async () => {
aw.seed('websiteProjects', { $id: 'pr1', customerId: 'c1', projectName: 'Retry' })
await checkout('pr1')
const id = sessions().at(-1).id
const wosBefore = workorders().length
const mailsBefore = mails.sent.length
aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })
const first = await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
assert.equal(first.status, 500)
const stuck = await sf.getFeatureDoc('c1', 'pr1', 'news')
assert.equal(stuck.state, 'setup')
assert.ok(!stuck.ticketId)
assert.equal(mails.sent.length, mailsBefore)
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))).status, 200)
const fixed = await sf.getFeatureDoc('c1', 'pr1', 'news')
assert.ok(fixed.ticketId)
assert.equal(workorders().length, wosBefore + 1)
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
// weiterer Retry: nichts mehr
await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
assert.equal(workorders().length, wosBefore + 1)
assert.equal(mails.sent.length - mailsBefore, 2)
})
test('rechnungslink_wird_nachgetragen', async () => {
aw.seed('websiteProjects', { $id: 'pi1', customerId: 'c1', projectName: 'Rechnung' })
await checkout('pi1')
const session = sessions().at(-1)
await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pi1')))
const listed = async () => (await get('/api/funktionen', { cookie })).projects.find((p) => p.projectId === 'pi1').features.news
assert.equal((await listed()).invoiceUrl, null)
st.invoices.set('in_late', { id: 'in_late', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/late' })
session.invoice = 'in_late'
assert.equal((await listed()).invoiceUrl, 'https://invoice.stripe.test/late')
const doc = await sf.getFeatureDoc('c1', 'pi1', 'news')
assert.equal(doc.stripeInvoiceId, 'in_late')
// Stripe-Fehler lassen die Liste nicht scheitern
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), invoiceUrl: '' })
st.failNext({ method: 'GET', path: /checkout\/sessions/, status: 400 })
assert.equal((await get('/api/funktionen', { cookie })).projects.length > 0, true)
})
test('bezahlt_ohne_passende_bestellung_meldet_dem_team', async () => {
aw.seed('websiteProjects', { $id: 'pu1', customerId: 'c1', projectName: 'Unklar' })
const mailsBefore = mails.sent.length
const res = await postWebhook(event('checkout.session.completed', paidSession('cs_verwaist', 'pu1')))
assert.equal(res.status, 200)
const notice = mails.sent.slice(mailsBefore)
assert.equal(notice.length, 1)
assert.equal(notice[0].subject, 'Neue Funktion bestellt: News-Pinnwand')
assert.match(notice[0].text, /cs_verwaist/)
assert.match(notice[0].text, /nicht zuzuordnen/)
assert.doesNotMatch(notice[0].text, /Zahlung fehlgeschlagen/)
assert.equal(workorders().filter((t) => t.topic.includes('Unklar')).length, 0)
})
test('abgeschaltete_funktion_nicht_buchbar', async () => {
aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Abgeschaltet' })
const doc = await sf.adminActivate('c1', 'pd1', 'news', { by: 'test' })
await sf.disable(doc.$id, { by: 'test' })
const before = sessions().length
const res = await checkout('pd1')
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.')
assert.equal(sessions().length, before)
})
test('statische_quellen: news_label, minmax_grid, ticket_anleitung', () => {
const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')
assert.match(read('public/app.js'), /FEATURE_LABELS = \{[^}]*news: 'News-Pinnwand'/)
assert.doesNotMatch(read('public/app.js'), /FEATURE_LABELS\[f\.featureKey\] \|\| f\.featureKey/)
const embed = read('public/embed/news.js')
assert.ok(embed.includes('grid-template-columns:minmax(0,1.25fr) minmax(0,1fr)'))
assert.ok(embed.includes('.wkn-combo-in{grid-template-columns:minmax(0,1fr)}'))
assert.ok(!read('public/dashboard.html').includes('id="view-news" aria-live'))
})
test('ticket_details_nennen_die_anleitung', () => {
const doc = [...aw.docs('workorders').values()].find((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
assert.ok(doc.details.includes('Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)'))
})
+28
View File
@@ -462,3 +462,31 @@ test('Fassung 5: Widerrufsbelehrung und Formular wortgleich mit Fassung 4', () =
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung') const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular'))) assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular')))
}) })
test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', () => {
const html = read('public/datenschutz.html')
const a = html.indexOf('id="news"')
assert.ok(a > html.indexOf('6. Kundenportal'), 'Abschnitt 6a steht nach Abschnitt 6')
const b = html.indexOf('<h2>7. ', a)
assert.ok(b > a)
const news = strip(html.slice(a, b))
for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s)
assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend'))
assert.ok(html.includes('<h2>7. Abos und Zahlung über Stripe</h2>'), 'keine Umnummerierung')
assert.ok(news.includes('nach etwa 24 Stunden') && !news.includes('spätestens nach 24 Stunden'))
assert.ok(news.includes('nach den Fristen in Abschnitt 7') && !news.includes('Abschnitt 6 und 7'))
assert.ok(!news.includes('Endet die Zusammenarbeit, schalten wir'))
const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend'))
const text = strip(lb)
assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir'))
assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß'))
for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
// 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt
assert.ok(!muster.includes('nach 14 Tagen gelöscht') || strip(html).includes('Die Protokolle löschen wir nach 14 Tagen'))
assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
})
+7 -4
View File
@@ -6,17 +6,20 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url) const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail // Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand)
const EXPECTED = [ const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12', 'K12',
'K13', 'K13',
'K14', 'K14',
'K15',
'K16',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12', 'T12',
'T13', 'T13',
'T14', 'T14',
'T15', 'T15',
'T16',
] ]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -33,7 +36,7 @@ for (const id of EXPECTED) {
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1] const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail) // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]') assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
@@ -257,11 +260,11 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`) for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`) for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
assert.ok(loadTemplate('K04').placeholders.includes('bestellung')) assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen']) assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`) for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`) for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`) for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`) for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' } const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' }
const speicher = renderMail('K03', k03, { zusatzspeicher: true }) const speicher = renderMail('K03', k03, { zusatzspeicher: true })
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-images-'))
const { storeNewsImage, claimNewsImage, deleteNewsImage, newsImagePath, purgeUnclaimedNewsImages, newsImageRateLimited, NEWS_IMAGE_MAX_BYTES } =
await import('../server/services/newsImages.js')
const solid = (width, height) => sharp({ create: { width, height, channels: 3, background: '#c33' } })
test('exif_gps_entfernt_und_gedreht', async () => {
const input = await solid(1200, 800)
.withMetadata({ orientation: 6 })
.withExif({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '48/1 30/1 0/1' } })
.jpeg().toBuffer()
const inMeta = await sharp(input).metadata()
assert.ok(inMeta.exif, 'Fixture hat EXIF')
assert.equal(inMeta.orientation, 6)
const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input })
for (const size of ['l', 's']) {
const meta = await sharp(newsImagePath('p1', imageId, size)).metadata()
assert.equal(meta.exif, undefined)
assert.ok(meta.width < meta.height, `${size}: gedreht`)
}
})
test('verkleinert_auf_1600_und_480', async () => {
const input = await solid(4000, 3000).png().toBuffer()
const { imageId, w, h } = await storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input })
assert.deepEqual([w, h], [1600, 1200])
const l = await sharp(newsImagePath('p1', imageId, 'l')).metadata()
const s = await sharp(newsImagePath('p1', imageId, 's')).metadata()
assert.deepEqual([l.format, l.width, l.height], ['webp', 1600, 1200])
assert.deepEqual([s.format, s.width, s.height], ['webp', 480, 360])
})
test('zu_gross_413', async () => {
await assert.rejects(
storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: Buffer.alloc(NEWS_IMAGE_MAX_BYTES + 1) }),
{ status: 413 },
)
})
test('falscher_typ_415', async () => {
await assert.rejects(
storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: Buffer.from('%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n') }),
{ status: 415 },
)
})
test('pfad_nur_saubere_ids', () => {
assert.equal(newsImagePath('p1', '../x', 'l'), null)
assert.equal(newsImagePath('../p', 'abc-1', 'l'), null)
assert.ok(newsImagePath('p1', 'abc-1', 's').endsWith(path.join('p1', 'abc-1-s.webp')))
})
test('unbenutzte_nach_24h_weg', async () => {
const input = await solid(100, 100).png().toBuffer()
const old = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input })
const kept = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input })
const fresh = await storeNewsImage({ customerId: 'c1', projectId: 'p-purge', buffer: input })
await claimNewsImage('p-purge', kept.imageId)
const backdate = async (id) => {
const f = path.join(process.env.NEWS_UPLOAD_DIR, 'p-purge', `${id}.json`)
const meta = JSON.parse(await readFile(f, 'utf8'))
meta.createdAt = new Date(Date.now() - 25 * 3600 * 1000).toISOString()
await writeFile(f, JSON.stringify(meta))
}
await backdate(old.imageId)
await backdate(kept.imageId)
assert.equal(await purgeUnclaimedNewsImages(), 1)
assert.equal(existsSync(newsImagePath('p-purge', old.imageId, 'l')), false)
assert.equal(existsSync(newsImagePath('p-purge', old.imageId, 's')), false)
assert.equal(existsSync(newsImagePath('p-purge', kept.imageId, 'l')), true)
assert.equal(existsSync(newsImagePath('p-purge', fresh.imageId, 'l')), true)
})
test('zu_viele_pixel_415', async () => {
const input = await solid(8000, 8000).png({ compressionLevel: 9 }).toBuffer()
assert.ok(input.length < NEWS_IMAGE_MAX_BYTES)
await assert.rejects(storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input }), { status: 415 })
})
test('kaputtes_bild_415', async () => {
const input = (await solid(100, 100).png().toBuffer()).subarray(0, 60)
await assert.rejects(storeNewsImage({ customerId: 'c1', projectId: 'p1', buffer: input }), { status: 415 })
})
test('loeschen_entfernt_dateien_und_sidecar', async () => {
const input = await solid(100, 100).png().toBuffer()
const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'p-del', buffer: input })
const sidecar = path.join(process.env.NEWS_UPLOAD_DIR, 'p-del', `${imageId}.json`)
assert.equal(existsSync(sidecar), true)
await deleteNewsImage('p-del', imageId)
assert.equal(existsSync(newsImagePath('p-del', imageId, 'l')), false)
assert.equal(existsSync(newsImagePath('p-del', imageId, 's')), false)
assert.equal(existsSync(sidecar), false)
await deleteNewsImage('p-del', imageId)
})
test('rate_limit_20_pro_stunde', () => {
for (let i = 0; i < 20; i++) assert.equal(newsImageRateLimited('rl'), false)
assert.equal(newsImageRateLimited('rl'), true)
assert.equal(newsImageRateLimited('other'), false)
})
+145
View File
@@ -0,0 +1,145 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-posts-'))
const aw = await startAppwriteMock({ schema: { newsPosts: schemaKeys('newsPosts') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const np = await import('../server/services/newsPosts.js')
const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js')
const { existsSync } = await import('node:fs')
const { addMonths } = await import('../server/services/stripeBilling.js')
const NOW = new Date('2026-10-01T10:00:00.000Z')
const base = { type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const mk = (projectId, input = {}, now = NOW) => np.createPost({ customerId: 'c1', projectId, input: { ...base, ...input }, now })
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
test('ablauf_grenzen_berlin', async () => {
const until = np.visibleUntilFor('date', NOW, '2026-10-25')
assert.equal(until.toISOString(), '2026-10-25T22:59:59.999Z')
const post = { visibleFrom: NOW.toISOString(), visibleUntil: until.toISOString(), takenDownAt: null }
assert.equal(np.isHanging(post, new Date(until.getTime() - 1)), true)
assert.equal(np.isHanging(post, until), false)
const open = { visibleFrom: NOW.toISOString(), visibleUntil: null, takenDownAt: null }
assert.equal(np.isHanging(open, new Date('2030-01-01')), true)
assert.equal(np.isHanging({ ...open, takenDownAt: NOW.toISOString() }, new Date('2026-10-02')), false)
})
test('dauer_werte', () => {
assert.equal(np.visibleUntilFor('1w', NOW).getTime() - NOW.getTime(), 7 * 86400000)
assert.equal(np.visibleUntilFor('2w', NOW).getTime() - NOW.getTime(), 14 * 86400000)
const jan = new Date('2027-01-31T09:00:00.000Z')
assert.equal(np.visibleUntilFor('1m', jan).toISOString(), addMonths(jan, 1).toISOString())
assert.equal(np.visibleUntilFor('1m', jan).toISOString(), '2027-02-28T09:00:00.000Z')
assert.equal(np.visibleUntilFor('3m', NOW).toISOString(), addMonths(NOW, 3).toISOString())
assert.equal(np.visibleUntilFor('open', NOW), null)
})
test('datum_grenzen_400', () => {
assert.throws(() => np.visibleUntilFor('date', NOW, '2026-10-01'), { status: 400, field: 'date' })
assert.throws(() => np.visibleUntilFor('date', NOW, '2027-10-02'), { status: 400 })
assert.throws(() => np.visibleUntilFor('date', NOW, 'morgen'), { status: 400 })
assert.throws(() => np.visibleUntilFor('date', NOW, '2026-11-31'), { status: 400, field: 'date' })
assert.throws(() => np.visibleUntilFor('date', NOW, '2027-02-31'), { status: 400, field: 'date' })
assert.ok(np.visibleUntilFor('date', NOW, '2026-10-02'))
assert.ok(np.visibleUntilFor('date', NOW, '2027-10-01'))
})
test('validierung', () => {
assert.throws(() => np.validatePostInput({ ...base, title: 'x'.repeat(61) }), { status: 400, field: 'title' })
assert.throws(() => np.validatePostInput({ ...base, type: 'photo' }), { status: 400, field: 'imageId' })
assert.throws(() => np.validatePostInput({ ...base, paper: 'lila' }), { status: 400, field: 'paper' })
assert.throws(() => np.validatePostInput({ ...base, text: '' }), { status: 400, field: 'text' })
assert.equal(np.validatePostInput({ ...base, title: undefined }).title, '')
assert.equal(np.validatePostInput({ ...base, type: 'photo', title: '', text: '', imageId: 'i1' }).title, '')
assert.throws(() => np.validatePostInput({ ...base, text: 'x'.repeat(401) }), { status: 400, field: 'text' })
assert.throws(() => np.validatePostInput({ ...base, type: 'combo', imageId: 'i1', text: '' }), { status: 400, field: 'text' })
assert.equal(np.validatePostInput({ ...base, type: 'photo', text: '', imageId: 'i1' }).imageId, 'i1')
})
test('abnehmen_und_wieder_aufhaengen', async () => {
const post = await mk('pa')
assert.equal((await np.listPosts('pa', NOW)).hanging.length, 1)
const later = new Date('2026-10-05T08:00:00.000Z')
await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'takeDown' }, now: later })
const l1 = await np.listPosts('pa', later)
assert.equal(l1.hanging.length, 0)
assert.equal(l1.expired.length, 1)
const re = await np.updatePost({ customerId: 'c1', projectId: 'pa', id: post.$id, input: { action: 'rehang', duration: '2w' }, now: later })
assert.equal(re.visibleFrom, later.toISOString())
assert.equal(re.visibleUntil, new Date(later.getTime() + 14 * 86400000).toISOString())
assert.equal((await np.listPosts('pa', later)).hanging.length, 1)
})
test('oeffentlich_nur_haengende_ohne_interna', async () => {
for (let i = 0; i < 8; i++) await mk('pb', { title: `Beitrag ${i}` }, new Date(NOW.getTime() + i * 1000))
for (let i = 0; i < 2; i++) await mk('pb', { duration: '1w' }, new Date('2026-09-01T10:00:00.000Z'))
const { imageId } = await storeNewsImage({ customerId: 'c1', projectId: 'pb', buffer: await png() })
await mk('pb', { type: 'combo', imageId, title: 'Mit Foto' }, new Date(NOW.getTime() + 60000))
const after10 = new Date(NOW.getTime() + 120000)
const p1 = await np.publicPage('pb', 1, after10)
assert.equal(p1.posts.length, 6)
assert.equal(p1.hasMore, true)
assert.equal(p1.posts[0].title, 'Mit Foto')
assert.deepEqual({ ...p1.posts[0].image, v: undefined }, { w: 40, h: 30, v: undefined })
assert.ok(Number.isFinite(p1.posts[0].image.v), 'v = Cache-Buster fuer die Route')
assert.equal(p1.posts[0].date, '2026-10-01')
assert.equal(p1.posts[1].image, null)
for (const p of p1.posts) {
assert.deepEqual(Object.keys(p).sort(), ['date', 'id', 'image', 'paper', 'text', 'title', 'type'])
}
const p2 = await np.publicPage('pb', 2, after10)
assert.equal(p2.posts.length, 3)
assert.equal(p2.hasMore, false)
})
test('fremder_kunde_kann_nicht_aendern', async () => {
const post = await mk('pc')
await assert.rejects(np.updatePost({ customerId: 'c2', projectId: 'pc', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 })
await assert.rejects(np.updatePost({ customerId: 'c1', projectId: 'other', id: post.$id, input: { action: 'takeDown' }, now: NOW }), { status: 403 })
await assert.rejects(np.deletePost({ customerId: 'c2', projectId: 'pc', id: post.$id }), { status: 403 })
await assert.rejects(np.deletePost({ customerId: 'c1', projectId: 'pc', id: 'gibtsnicht' }), { status: 404 })
})
test('bild_gehoert_zum_projekt_und_wird_mitgeloescht', async () => {
const a = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() })
await assert.rejects(mk('pe', { type: 'photo', imageId: a.imageId }), { status: 400, field: 'imageId' })
const post = await mk('pd', { type: 'photo', imageId: a.imageId })
const b = await storeNewsImage({ customerId: 'c1', projectId: 'pd', buffer: await png() })
await np.updatePost({ customerId: 'c1', projectId: 'pd', id: post.$id, input: { ...base, type: 'photo', imageId: b.imageId }, now: NOW })
assert.equal(existsSync(newsImagePath('pd', a.imageId, 'l')), false)
assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), true)
await np.deletePost({ customerId: 'c1', projectId: 'pd', id: post.$id })
assert.equal(existsSync(newsImagePath('pd', b.imageId, 'l')), false)
assert.equal((await np.listPosts('pd', NOW)).hanging.length, 0)
})
test('bearbeiten_datum_bild_regeln', async () => {
const post = await mk('pf', { duration: 'date', date: '2026-10-10' })
const edit = (input, now) => np.updatePost({ customerId: 'c1', projectId: 'pf', id: post.$id, input: { ...base, duration: 'date', ...input }, now })
const late = new Date('2026-10-10T08:00:00.000Z')
// unveraendertes Datum (heute) bleibt speicherbar, geaendertes wird geprueft
const ok = await edit({ date: '2026-10-10', title: 'Neu' }, late)
assert.equal(ok.title, 'Neu')
assert.equal(ok.visibleUntil, post.visibleUntil)
await assert.rejects(edit({ date: '2026-10-11' }, new Date('2026-10-11T08:00:00.000Z')), { status: 400, field: 'date' })
// Bild eines anderen Beitrags
const img = await storeNewsImage({ customerId: 'c1', projectId: 'pf', buffer: await png() })
await mk('pf', { type: 'photo', imageId: img.imageId })
await assert.rejects(edit({ type: 'photo', imageId: img.imageId, date: '2026-10-10' }, late), { status: 400, field: 'imageId' })
})
test('create_ohne_bild_belegt_nichts', async () => {
await assert.rejects(mk('pg', { type: 'photo', imageId: 'nope' }), { status: 400, field: 'imageId' })
assert.equal((await np.listPosts('pg', NOW)).hanging.length, 0)
})
+178
View File
@@ -0,0 +1,178 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'news-routes-'))
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), newsPosts: schemaKeys('newsPosts') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp, get, post, sessionCookie } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: newsRoutes } = await import('../server/routes/news.js')
const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js')
const app = await startTestApp({ '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
const KEY = 'abcdefghijklmnop'
const KEY2 = 'bcdefghijklmnopq'
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Rate' })
const feature = (projectId, customerId, state, siteKey) =>
aw.seed('portalFeatures', { $id: `f_${projectId}_news`, customerId, projectId, featureKey: 'news', state, enabled: state === 'active', siteKey })
feature('p1', 'c1', 'active', KEY)
feature('p2', 'c1', 'setup', KEY2)
feature('p3', 'c2', 'active', 'cdefghijklmnopqr')
const cookie = customerCookie('c1')
const call = (method, url, { body, cookie: c = cookie, headers = {} } = {}) =>
fetch(app.url + url, {
method,
headers: { ...(c ? { cookie: c } : {}), ...(body !== undefined && !Buffer.isBuffer(body) ? { 'content-type': 'application/json' } : {}), ...headers },
body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
})
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
const upload = (projectId, c = cookie) =>
png().then((buf) => call('POST', `/api/news/image?projectId=${projectId}`, { body: buf, cookie: c, headers: { 'content-type': 'application/octet-stream' } }))
test('nicht_aktiv_409', async () => {
const res = await post('/api/news', { ...note, projectId: 'p2' }, { cookie })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'News ist für diese Website nicht aktiv.')
})
test('fremdes_projekt_403_und_viewAs_403', async () => {
assert.equal((await post('/api/news', { ...note, projectId: 'p3' }, { cookie })).status, 403)
const admin = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', viewAs: true })
assert.equal((await post('/api/news', note, { cookie: admin })).status, 403)
})
test('kunde_crud', async () => {
const created = await post('/api/news', note, { cookie })
assert.equal(created.status, 201)
const id = created.body.$id
const list = await get('/api/news?projectId=p1', { cookie })
assert.equal(list.hanging.length, 1)
assert.equal(list.liveUrl, 'https://baeckerei.de')
const patched = await call('PATCH', `/api/news/${id}`, { body: { projectId: 'p1', action: 'takeDown' } })
assert.equal(patched.status, 200)
const after = await get('/api/news?projectId=p1', { cookie })
assert.deepEqual([after.hanging.length, after.expired.length], [0, 1])
assert.equal((await call('DELETE', `/api/news/${id}?projectId=p1`)).status, 204)
assert.equal((await get('/api/news?projectId=p1', { cookie })).expired.length, 0)
})
test('upload_und_vorschau', async () => {
const res = await upload('p1')
assert.equal(res.status, 201)
const { imageId } = await res.json()
const ok = await call('GET', `/api/news/image/p1/${imageId}-s.webp`)
assert.equal(ok.status, 200)
assert.equal(ok.headers.get('content-type'), 'image/webp')
await ok.arrayBuffer()
const other = await call('GET', `/api/news/image/p1/${imageId}-s.webp`, { cookie: customerCookie('c2') })
assert.equal(other.status, 403)
assert.equal((await call('GET', '/api/news/image/p1/..%2Fx-l.webp')).status, 404)
})
test('oeffentlich_cors_cache', async () => {
const { imageId } = await (await upload('p1')).json()
const created = await post('/api/news', { ...note, type: 'combo', imageId }, { cookie })
assert.equal(created.status, 201)
const res = await call('GET', `/api/public/news/${KEY}`, { cookie: null })
assert.equal(res.status, 200)
assert.equal(res.headers.get('access-control-allow-origin'), '*')
assert.equal(res.headers.get('cache-control'), 'public, max-age=60')
const text = await res.text()
assert.ok(!text.includes('"c1"') && !text.includes('"p1"') && !text.includes('customerId') && !text.includes('projectId'))
const body = JSON.parse(text)
assert.equal(body.posts.length, 1)
assert.equal(body.posts[0].image.src, `/api/public/news/${KEY}/img/${created.body.$id}.webp?v=${Date.parse(created.body.updatedAt)}`)
assert.equal(body.posts[0].image.srcSmall, `/api/public/news/${KEY}/img/${created.body.$id}-s.webp?v=${Date.parse(created.body.updatedAt)}`)
const etag = res.headers.get('etag')
assert.match(etag, /^W\//)
const again = await call('GET', `/api/public/news/${KEY}`, { cookie: null, headers: { 'if-none-match': etag } })
assert.equal(again.status, 304)
const img = await call('GET', body.posts[0].image.src, { cookie: null })
assert.equal(img.status, 200)
assert.equal(img.headers.get('cache-control'), 'public, max-age=86400')
assert.equal(img.headers.get('x-content-type-options'), 'nosniff')
assert.equal(img.headers.get('access-control-allow-origin'), '*')
await img.arrayBuffer()
const pre = await call('OPTIONS', `/api/public/news/${KEY}`, { cookie: null })
assert.equal(pre.status, 204)
})
test('oeffentlich_unbekannt_leer', async () => {
for (const key of ['zzzzzzzzzzzzzzzz', 'kaputt', KEY2]) {
const res = await call('GET', `/api/public/news/${key}`, { cookie: null })
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { posts: [], hasMore: false })
}
})
test('bild_nur_wenn_haengend', async () => {
const { imageId } = await (await upload('p1')).json()
const created = await post('/api/news', { ...note, type: 'photo', imageId }, { cookie })
const url = `/api/public/news/${KEY}/img/${created.body.$id}.webp`
assert.equal((await call('GET', url, { cookie: null })).status, 200)
await call('PATCH', `/api/news/${created.body.$id}`, { body: { projectId: 'p1', action: 'takeDown' } })
assert.equal((await call('GET', url, { cookie: null })).status, 404)
assert.equal((await call('GET', `/api/public/news/${KEY}/img/nichtda.webp`, { cookie: null })).status, 404)
})
test('rate_limit_schreiben', async () => {
const c2 = customerCookie('c2')
const statuses = []
for (let i = 0; i < 31; i++) statuses.push((await post('/api/news', { ...note, projectId: 'p3' }, { cookie: c2 })).status)
assert.ok(statuses.slice(0, 30).every((s) => s === 201), statuses.join())
assert.equal(statuses[30], 429)
})
test('cache_buster_aendert_sich_beim_bildtausch', async () => {
const first = await (await upload('p1')).json()
const created = await post('/api/news', { ...note, type: 'photo', imageId: first.imageId }, { cookie })
const src = async () => (await (await call('GET', `/api/public/news/${KEY}`, { cookie: null })).json()).posts.find((p) => p.id === created.body.$id).image
const before = await src()
assert.match(before.src, /\.webp\?v=\d+$/)
assert.match(before.srcSmall, /-s\.webp\?v=\d+$/)
await new Promise((r) => setTimeout(r, 15))
const second = await (await upload('p1')).json()
const res = await call('PATCH', `/api/news/${created.body.$id}`, { body: { ...note, type: 'photo', imageId: second.imageId } })
assert.equal(res.status, 200)
const now = await src()
assert.notEqual(now.src, before.src)
assert.equal((await call('GET', now.src, { cookie: null })).status, 200)
})
test('tippfehler_zaehlt_nicht_gegen_schreiblimit', async () => {
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c3', projectName: 'Tippfehler' })
feature('p4', 'c3', 'active', 'defghijklmnopqrs')
const c = customerCookie('c3')
for (let i = 0; i < 35; i++) assert.equal((await post('/api/news', { ...note, projectId: 'gibtsnicht' }, { cookie: c })).status, 403)
assert.equal((await post('/api/news', { ...note, projectId: 'p4' }, { cookie: c })).status, 201)
})
test('head_erlaubt', async () => {
assert.equal((await call('HEAD', `/api/public/news/${KEY}`, { cookie: null })).status, 200)
})
test('oeffentlich_rate_limit_429', async () => {
const ip = { 'x-forwarded-for': '203.0.113.77' }
const statuses = []
for (let i = 0; i < 121; i++) statuses.push((await call('GET', `/api/public/news/${KEY2}`, { cookie: null, headers: ip })).status)
assert.ok(statuses.slice(0, 120).every((s) => s === 200))
assert.equal(statuses[120], 429)
})
+26
View File
@@ -452,6 +452,31 @@ test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerr
assert.match(t09().text, /Menge .*zurück/) assert.match(t09().text, /Menge .*zurück/)
}) })
test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Funktion, Projekt und Fristende', async () => {
await resetSubH({ units: 0 })
const orderedAt = daysAgo(3)
aw.seed('websiteProjects', { $id: 'pf1', customerId: 'c1', projectName: 'Bäckerei Funktion' })
aw.seed('portalFeatures', { $id: 'f_pf1_news', customerId: 'c1', projectId: 'pf1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt })
const before = st.calls.length
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
assert.deepEqual(await listContractEvents('c1'), [], 'kein Vertragsereignis, kein Abo betroffen')
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
assert.equal(aw.docs('portalFeatures').get('f_pf1_news').state, 'setup')
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
assert.doesNotMatch(k04().text, /Es erfolgen keine weiteren Abbuchungen/)
const team = t09().text
assert.match(team, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(team, /NICHTS automatisch geändert/)
assert.match(team, /Bäckerei Funktion: in Einrichtung/)
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)))
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /KEIN aktives Abo/)
aw.docs('portalFeatures').delete('f_pf1_news')
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => { test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
const startedAt = daysAgo(2) const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } }) await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
@@ -656,6 +681,7 @@ test('widerrufen.html: Radioknöpfe order', () => {
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/) assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/) assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/) assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
}) })
+42
View File
@@ -15,11 +15,14 @@ const aw = await startAppwriteMock({
subscriptions: schemaKeys('subscriptions'), subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'), contractEvents: schemaKeys('contractEvents'),
takeoverCredits: schemaKeys('takeoverCredits'), takeoverCredits: schemaKeys('takeoverCredits'),
newsPosts: schemaKeys('newsPosts'),
portalFeatures: schemaKeys('portalFeatures'),
}, },
}) })
const st = await startFakeStripe() const st = await startFakeStripe()
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-')) const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
process.env.TAKEOVER_UPLOAD_DIR = uploadDir process.env.TAKEOVER_UPLOAD_DIR = uploadDir
process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news')
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js') const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
const { run } = await import('../scripts/loeschfristen.mjs') const { run } = await import('../scripts/loeschfristen.mjs')
@@ -264,3 +267,42 @@ test('genehmigter Antrag ohne decidedAt: übersprungen und gemeldet, kein Rückg
assert.ok(plan.skipped.some((t) => t.id === 'nd_tk')) assert.ok(plan.skipped.some((t) => t.id === 'nd_tk'))
assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/) assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/)
}) })
test('retention_loescht_news: Beiträge, Fotos und Funktionen fälliger Kunden', async () => {
const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js')
const sharp = (await import('sharp')).default
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
sub('rn_sub', 'rn', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
sub('rn2_sub', 'rn2', { status: 'active' }) // laufendes Abo: nichts davon wird angefasst
stripeSub('rn_sub', 'rn')
const { imageId } = await storeNewsImage({ customerId: 'rn', projectId: 'rnp', buffer: await png() })
const other = await storeNewsImage({ customerId: 'rn2', projectId: 'rntwop', buffer: await png() })
aw.seed('newsPosts', { $id: 'rn_post1', customerId: 'rn', projectId: 'rnp', type: 'photo', imageId, title: '', text: '' })
aw.seed('newsPosts', { $id: 'rn_post2', customerId: 'rn', projectId: 'rnp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' })
aw.seed('newsPosts', { $id: 'rn2_post', customerId: 'rn2', projectId: 'rntwop', type: 'photo', imageId: other.imageId, title: '', text: '' })
aw.seed('portalFeatures', { $id: 'rn_feat', customerId: 'rn', projectId: 'rnp', featureKey: 'news', state: 'active', enabled: true })
aw.seed('portalFeatures', { $id: 'rn2_feat', customerId: 'rn2', projectId: 'rntwop', featureKey: 'news', state: 'active', enabled: true })
const plan = await findDueDeletions(JAN_2026)
const entry = plan.news.find((n) => n.customerId === 'rn')
assert.deepEqual(entry.posts.map((p) => p.id).sort(), ['rn_post1', 'rn_post2'])
assert.deepEqual(entry.posts.find((p) => p.id === 'rn_post1'), { id: 'rn_post1', projectId: 'rnp', imageId })
assert.deepEqual(entry.featureIds, ['rn_feat'])
assert.ok(!plan.news.some((n) => n.customerId === 'rn2'))
assert.match(formatReport(plan), /Kunde rn: 2 News-Beitrag\/-Beiträge mit 1 Foto\(s\) zu löschen, 1 Funktion\(en\) abzuschalten/)
const result = await applyDeletions(plan)
assert.deepEqual(result.failed, [])
assert.equal(result.deleted.newsPosts, 2)
assert.equal(result.deleted.newsImages, 1)
assert.equal(result.deleted.features, 1)
assert.match(formatReport(plan, result), /News: 2 Beitrag\/Beiträge und 1 Foto\(s\) gelöscht, 1 Funktion\(en\) abgeschaltet/)
assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn').length, 0)
assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn2').length, 1)
assert.equal(existsSync(newsImagePath('rnp', imageId, 'l')), false)
assert.equal(existsSync(newsImagePath('rntwop', other.imageId, 'l')), true)
const feat = [...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn_feat')
assert.equal(feat.state, 'disabled')
assert.equal(feat.enabled, false)
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn2_feat').state, 'active')
})
+164
View File
@@ -0,0 +1,164 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const sf = await import('../server/services/siteFeatures.js')
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' })
aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei' })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café', subdomain: 'cafe' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd' })
aw.seed('websiteProjects', { $id: 'p_' + 'x'.repeat(40), customerId: 'c1', projectName: 'Lang' })
const consent = { text: 'Ich stimme zu', at: '2026-09-30T10:00:00.000Z', ip: '1.2.3.4', userAgent: 'UA' }
const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
test('ohne_dokument_ist_off', async () => {
const list = await sf.listCustomerFeatures('c1')
assert.equal(list.length, 3)
for (const p of list) assert.equal(p.features.news.state, 'off')
assert.equal(list.find((p) => p.projectId === 'p1').liveUrl, 'https://baeckerei.de')
})
test('bezahlt_legt_ticket_an', async () => {
const ordered = await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_1', consent })
assert.equal(ordered.state, 'ordered')
assert.equal(ordered.enabled, false)
assert.equal(ordered.consentIp, '1.2.3.4')
assert.match(ordered.siteKey, /^[a-z2-7]{16}$/)
const { doc, created } = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(created, true)
assert.equal(doc.state, 'setup')
assert.equal(doc.enabled, false)
assert.equal(doc.unlockedByPurchase, true)
assert.ok(doc.ticketId)
assert.equal(tickets().length, 1)
assert.match(tickets()[0].topic, /^News-Pinnwand einbauen – Bäckerei/)
assert.ok(tickets()[0].details.includes(`data-webklar-news="${doc.siteKey}"`))
const view = (await sf.listCustomerFeatures('c1')).find((p) => p.projectId === 'p1').features.news
assert.equal(view.state, 'setup')
assert.ok(view.ticketWoid)
})
test('webhook_idempotent', async () => {
const again = await sf.markPaid({ sessionId: 'cs_1', invoiceId: 'in_1', invoiceUrl: 'https://x/inv' })
assert.equal(again.created, false)
assert.equal(tickets().length, 1)
})
test('ticket_geschlossen_aktiviert', async () => {
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), [])
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'setup')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c2' }), [])
const now = new Date('2026-10-01T08:00:00Z')
const done = await sf.activateClosedTickets({ now })
assert.equal(done.length, 1)
assert.equal(done[0].state, 'active')
assert.equal(done[0].enabled, true)
assert.equal(done[0].activatedAt, now.toISOString())
assert.equal((await sf.findActiveBySiteKey(doc.siteKey)).$id, doc.$id)
})
test('fehlgeschlagene_zahlung_setzt_off', async () => {
await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: 'cs_2', consent })
const doc = await sf.markCheckoutFailed('cs_2')
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.equal(await sf.markCheckoutFailed('cs_unbekannt'), null)
})
test('fremdes_projekt_403', async () => {
await assert.rejects(() => sf.assertOwnProject('c1', 'p3'), { status: 403 })
await assert.rejects(() => sf.assertOwnProject('c1', 'gibts_nicht'), { status: 403 })
assert.equal((await sf.assertOwnProject('c1', 'p1')).$id, 'p1')
})
test('sitekey_format', () => {
const keys = new Set()
for (let i = 0; i < 100; i += 1) {
const k = sf.newSiteKey()
assert.match(k, /^[a-z2-7]{16}$/)
keys.add(k)
}
assert.equal(keys.size, 100)
})
test('admin_aktiviert_ohne_zahlung', async () => {
const before = tickets().length
const doc = await sf.adminActivate('c1', 'p2', 'news', { by: 'kenso' })
assert.equal(doc.state, 'active')
assert.equal(doc.enabled, true)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(tickets().length, before)
const off = await sf.disable(doc.$id, { by: 'kenso' })
assert.equal(off.state, 'disabled')
assert.equal(off.enabled, false)
})
test('lange_projekt_id_bekommt_gueltige_dokument_id', async () => {
const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {})
assert.ok(doc.$id.length <= 36)
})
test('bestellen_bei_aktiver_funktion_409', async () => {
const doc = await sf.adminActivate('c1', 'p1', 'news', {}).catch(() => null)
const active = doc || (await sf.getFeatureDoc('c1', 'p1', 'news'))
assert.equal(active.state, 'active')
await assert.rejects(
() => sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_x', consent }),
{ status: 409 }
)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'active')
})
test('parallele_webhooks_ein_ticket', async () => {
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Parallel' })
await sf.markOrdered({ customerId: 'c1', projectId: 'p4', key: 'news', sessionId: 'cs_par', consent })
const before = tickets().length
const res = await Promise.all([1, 2, 3].map(() => sf.markPaid({ sessionId: 'cs_par', invoiceId: 'in_p', invoiceUrl: 'u' })))
assert.equal(res.filter((r) => r.created).length, 1)
assert.equal(tickets().length, before + 1)
assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId)
})
test('abgelaufen_fasst_setup_und_active_nicht_an', async () => {
// cs_1 ist bezahlt ('setup' bzw. nach dem Schliessen des Tickets 'active'): ein spaetes expired aendert nichts
const before = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.notEqual(before.state, 'ordered')
const after = await sf.markCheckoutFailed('cs_1')
assert.equal(after.state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).purchaseStatus, 'paid')
})
test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
const pid = 'p_race'
aw.seed('websiteProjects', { $id: pid, customerId: 'c1', projectName: 'Rennen' })
await sf.markOrdered({ customerId: 'c1', projectId: pid, key: 'news', sessionId: 'cs_race', consent })
const ticketsBefore = tickets().length
await Promise.all([
sf.markPaid({ sessionId: 'cs_race', invoiceId: 'in_r', invoiceUrl: '' }),
sf.markCheckoutFailed('cs_race'),
])
const doc = await sf.getFeatureDoc('c1', pid, 'news')
// entweder bezahlt (mit Ticket) oder zurueckgesetzt (ohne): nie 'setup' ohne Ticket oder 'off' mit Ticket
if (doc.state === 'setup') {
assert.ok(doc.ticketId)
assert.equal(tickets().length, ticketsBefore + 1)
} else {
assert.equal(doc.state, 'off')
assert.equal(doc.ticketId || '', '')
assert.equal(tickets().length, ticketsBefore)
}
})
+18
View File
@@ -242,3 +242,21 @@ test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; stora
assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z') assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z')
assert.equal(doc.storageUnits, 3) assert.equal(doc.storageUnits, 3)
}) })
test('news_einrichtung_einmalpreis', async () => {
const before = st.calls.length
const id = await ensurePrice('newsSetup')
const calls = st.calls.slice(before)
const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(created.length, 1)
assert.equal(created[0].body['unit_amount'], '3000')
assert.equal(created[0].body['lookup_key'], 'webklar_news_einrichtung_3000')
assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring')
assert.equal(st.prices.get(id).recurring, null)
const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products')
assert.equal(products[0].body['metadata[webklar]'], 'news-einrichtung')
// zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden)
const again = st.calls.length
assert.equal(await ensurePrice('newsSetup'), id)
assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0)
})
+12
View File
@@ -287,3 +287,15 @@ test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern',
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402) await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402)
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c') assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c')
}) })
test('schema_funktionen_news', async () => {
const { schemaKeys } = await import('./support/schema.js')
const features = schemaKeys('portalFeatures')
for (const k of ['customerId', 'projectId', 'featureKey', 'enabled', 'unlockedByPurchase', 'purchaseStatus', 'state', 'siteKey', 'ticketId', 'stripeSessionId', 'stripeInvoiceId', 'invoiceUrl', 'orderedAt', 'activatedAt', 'disabledAt', 'consentText', 'consentAt', 'consentIp', 'consentUserAgent', 'adminNote']) {
assert.ok(features.includes(k), `portalFeatures.${k}`)
}
const news = schemaKeys('newsPosts')
for (const k of ['customerId', 'projectId', 'type', 'title', 'text', 'imageId', 'paper', 'duration', 'visibleFrom', 'visibleUntil', 'takenDownAt', 'createdAt', 'updatedAt']) {
assert.ok(news.includes(k), `newsPosts.${k}`)
}
})
+12
View File
@@ -314,10 +314,22 @@ export async function startFakeStripe() {
subscription: null, subscription: null,
metadata: data.metadata || {}, metadata: data.metadata || {},
subscription_data: data.subscription_data || {}, subscription_data: data.subscription_data || {},
status: 'open',
payment_status: 'unpaid',
invoice: null,
} }
checkoutSessions.set(id, obj) checkoutSessions.set(id, obj)
return [200, obj] return [200, obj]
} }
// Sitzung abrufen (Funktions-Checkout); expand=invoice ersetzt die Rechnungs-ID durch das Objekt.
// Tests setzen status/payment_status/invoice direkt im Objekt aus st.checkoutSessions.
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
const found = checkoutSessions.get(m[1])
if (!found) return noSuch('checkout.session', m[1])
const expandInvoice = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'invoice')
const inv = typeof found.invoice === 'string' ? invoices.get(found.invoice) : null
return [200, expandInvoice && inv ? { ...found, invoice: inv } : found]
}
if (method === 'GET' && path === '/v1/billing_portal/configurations') { if (method === 'GET' && path === '/v1/billing_portal/configurations') {
const active = query.get('active') const active = query.get('active')
const found = [...billingPortalConfigurations.values()].filter( const found = [...billingPortalConfigurations.values()].filter(
+95
View File
@@ -0,0 +1,95 @@
// Playwright-Check admin_funktionen: statischer Server + JSON-Fixtures, nicht Teil von npm test.
// Aufruf: node test/ui/admin-funktionen.ui.mjs
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }
const news = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: null, adminNote: '', ticketMissing: false, ...extra })
const projects = [
{ projectId: 'p1', projectName: 'Bäckerei', features: { news: news('off') } },
{ projectId: 'p2', projectName: 'Café', features: { news: news('setup', { ticketMissing: true }) } },
{ projectId: 'p3', projectName: 'Kiosk', features: { news: news('active', { ticketWoid: '1234' }) } },
]
const posts = []
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
if (url.pathname === '/api/auth/me') return json(res, 200, { authenticated: true, role: 'admin', admin: { name: 'Kenso', email: 'k@w.de' } })
if (url.pathname === '/api/portal-admin/customers') {
return json(res, 200, { customers: [{ id: 'c1', code: '1001', name: 'Test GmbH', email: 't@t.de', customerStatus: 'customer', portalAccessEnabled: true, projectCount: 3 }] })
}
if (url.pathname === '/api/portal-admin/funktionen/c1') return json(res, 200, { projects })
if (url.pathname === '/api/portal-admin/funktionen/c1/news' && req.method === 'POST') {
let raw = ''
for await (const chunk of req) raw += chunk
const body = JSON.parse(raw)
posts.push(body)
const p = projects.find((x) => x.projectId === body.projectId)
p.features.news = news(body.action === 'activate' ? 'active' : 'disabled', { adminNote: `2026-09-30T10:00:00.000Z Kenso: ${body.action}${body.note ? ` – ${body.note}` : ''}` })
return json(res, 200, { projects: [p] })
}
if (url.pathname.startsWith('/api/')) return json(res, 200, {})
try {
const file = path.join(pub, url.pathname === '/' ? 'admin.html' : url.pathname)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(await readFile(file))
} catch {
res.writeHead(404)
res.end()
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const browser = await chromium.launch()
try {
const page = await browser.newPage()
const dialogs = []
page.on('dialog', (d) => { dialogs.push(d.type()); d.dismiss() })
page.on('pageerror', (e) => check('keine Seitenfehler', false, e.message))
await page.goto(`http://127.0.0.1:${server.address().port}/admin.html`)
await page.click('.admin-tab[data-tab="customers"]')
await page.click('[data-features="c1"]')
await page.waitForSelector('[data-feature-project="p1"]')
const row = (id) => page.locator(`[data-feature-project="${id}"]`)
check('3 Zeilen', (await page.locator('[data-feature-project]').count()) === 3)
check('Ticket fehlt sichtbar', (await row('p2').innerText()).includes('Ticket fehlt'))
// Aktivieren: erst Rückfrage in der Zeile, nichts gesendet
await row('p1').locator('[data-feature-ask="activate"]').click()
check('Rückfrage statt Sofort-Aktion', posts.length === 0 && (await row('p1').innerText()).includes('ohne Zahlung aktivieren?'))
await row('p1').locator('[data-feature-no]').click()
check('Abbrechen stellt Knöpfe wieder her', (await row('p1').locator('[data-feature-ask="activate"]').count()) === 1 && posts.length === 0)
await row('p1').locator('[data-feature-ask="activate"]').click()
await row('p1').locator('[data-feature-note]').fill('Kulanz')
await row('p1').locator('[data-feature-yes]').click()
await page.waitForFunction(() => document.querySelector('[data-feature-project="p1"]')?.innerText.includes('Aktiv'))
check('POST activate mit Notiz', JSON.stringify(posts[0]) === JSON.stringify({ projectId: 'p1', action: 'activate', note: 'Kulanz' }), JSON.stringify(posts[0]))
check('Zeile zeigt Aktiv und Notiz', (await row('p1').innerText()).includes('Kenso: activate – Kulanz'))
// Abschalten
await row('p3').locator('[data-feature-ask="disable"]').click()
await row('p3').locator('[data-feature-yes]').click()
await page.waitForFunction(() => document.querySelector('[data-feature-project="p3"]')?.innerText.includes('Abgeschaltet'))
check('Abschalten aktualisiert Zeile', posts[1]?.action === 'disable')
check('keine Browser-Dialoge', dialogs.length === 0)
await page.screenshot({ path: '/tmp/claude-0/-root/a97f1ff7-2f71-45a7-bb9f-eaeacf7e64f1/scratchpad/admin-funktionen.png' })
} finally {
await browser.close()
server.close()
}
console.log(failed ? `admin_funktionen: ${failed} FEHLER` : 'admin_funktionen: alle Prüfungen bestanden')
process.exit(failed ? 1 : 0)
+225
View File
@@ -0,0 +1,225 @@
// Playwright-Checks Einbau-Skript public/embed/news.js: statischer Server fuer public/ + per page.route gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/embed.ui.mjs
// Screenshots landen in /root/reports/funktionen-news-ui/embed-*.png.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/funktionen-news-ui'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const DEMO = `${base}/embed/news-demo.html`
// ---- Fixtures ----
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
const PAPERS = ['sun', 'sky', 'coral', 'white']
const KINDS = ['combo', 'note', 'photo', 'note', 'combo', 'note', 'photo', 'note']
function makePosts(n, offset = 0) {
return Array.from({ length: n }, (_, k) => {
const i = k + offset
const type = KINDS[i % KINDS.length]
return {
id: `p${i}`, type, title: i === 3 ? '' : `Beitrag ${i + 1}`, text: type === 'photo' ? '' : `Text zu Beitrag ${i + 1}. Frisch reingekommen, komm vorbei.`,
paper: PAPERS[i % 4], date: '2026-09-29',
image: type === 'note' ? null : { w: 1200, h: 900, src: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1`, srcSmall: `/api/public/news/abcdefghijklmnop/img/p${i}.webp?v=1&s=1` },
}
})
}
async function mockApi(page, { total = 0, status = 200, custom = null } = {}) {
await page.route('**/api/public/news/**', async (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes('/img/')) return route.fulfill({ status: 200, contentType: 'image/png', body: PNG })
if (status !== 200) return route.fulfill({ status, contentType: 'application/json', body: '{"error":"down"}' })
if (custom) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts: custom, hasMore: false }) })
const pg = Number(url.searchParams.get('page') || 1)
const all = makePosts(total)
const posts = all.slice((pg - 1) * 6, pg * 6)
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ posts, hasMore: pg * 6 < total }) })
})
}
const browser = await chromium.launch()
const results = []
async function check(name, fn, { viewport = { width: 1280, height: 900 }, javaScriptEnabled = true } = {}) {
const ctx = await browser.newContext({ viewport, javaScriptEnabled })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
try {
await fn(page, errors)
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message}`)
}
await ctx.close()
}
function assert(cond, msg) { if (!cond) throw new Error(msg) }
await check('leer_bleibt_leerzustand', async (page, errors) => {
await mockApi(page, { total: 0 })
await page.goto(DEMO)
await page.waitForLoadState('networkidle')
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
assert((await page.locator('.wkn-board').count()) === 0, 'Brett trotz 0 Posts')
assert((await page.locator('#wkn-style').count()) <= 1, 'Style mehrfach')
assert(errors.length === 0, errors.join())
})
await check('ein_beitrag_neu_schild', async (page) => {
await mockApi(page, { total: 1 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin').count()) === 1, 'nicht genau 1 Zettel')
assert((await page.locator('.wkn-new').count()) === 1, 'Neu-Schild fehlt')
assert((await page.locator('.wkn-new').innerText()) === 'Neu', 'Schild-Text')
assert((await page.locator('#static-empty').count()) === 0, 'Leerzustand blieb stehen')
assert((await page.locator('.wkn-more').isHidden()), 'Knopf trotz hasMore=false sichtbar')
assert((await page.locator('#wkn-style').count()) === 1, 'Style nicht genau einmal')
assert((await page.locator('.wkn-date').first().innerText()).toLowerCase() === '29. sep', 'Datum: ' + await page.locator('.wkn-date').first().innerText())
})
await check('acht_beitraege_aeltere', async (page) => {
await mockApi(page, { total: 8 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin').count()) === 6, '6 erwartet')
const btn = page.getByRole('button', { name: 'Ältere Neuigkeiten ansehen' })
assert(await btn.isVisible(), 'Knopf fehlt')
await btn.click()
await page.waitForFunction(() => document.querySelectorAll('.wkn-pin').length === 8)
assert(await btn.isHidden(), 'Knopf nicht verschwunden')
assert((await page.locator('.wkn-new').count()) === 1, 'Neu nur am neuesten')
})
await check('xss_als_text', async (page, errors) => {
const evil = '<img src=x onerror=window.__x=1>'
await mockApi(page, { custom: [{ id: 'x', type: 'note', title: evil, text: evil, paper: 'sun', date: '2026-09-29', image: null }] })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
await page.waitForTimeout(300)
assert((await page.locator('.wkn-pin h3').innerText()) === evil, 'Titel nicht als Text')
assert((await page.locator('.wkn-pin img').count()) === 0, 'img-Element eingeschleust')
assert((await page.evaluate(() => window.__x)) === undefined, 'window.__x gesetzt')
})
await check('api_down_kein_fehler', async (page, errors) => {
await mockApi(page, { status: 503 })
await page.goto(DEMO)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(200)
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
assert(errors.length === 0, 'Exception: ' + errors.join())
})
await check('ohne_js', async (page) => {
await mockApi(page, { total: 3 })
await page.goto(DEMO)
assert(await page.locator('#static-empty').isVisible(), 'Leerzustand nicht sichtbar')
}, { javaScriptEnabled: false })
await check('handy_wischreihe', async (page) => {
await mockApi(page, { total: 6 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const info = await page.evaluate(() => {
const g = document.querySelector('.wkn-grid')
const cs = getComputedStyle(g)
return { snap: cs.scrollSnapType, ox: cs.overflowX, flex: cs.display, gw: g.scrollWidth, cw: g.clientWidth, doc: document.documentElement.scrollWidth, win: window.innerWidth, hint: getComputedStyle(document.querySelector('.wkn-swipehint')).display, dots: document.querySelectorAll('.wkn-dots i').length }
})
assert(/x\s+mandatory/.test(info.snap), 'kein Scroll-Snap: ' + info.snap)
assert(info.ox === 'auto' && info.flex === 'flex', 'keine Reihe')
assert(info.gw > info.cw, 'Reihe scrollt nicht')
assert(info.doc <= info.win, `Seite scrollt horizontal (${info.doc} > ${info.win})`)
assert(info.hint === 'flex' && info.dots === 6, 'Wisch-Hinweis/Punkte')
}, { viewport: { width: 390, height: 844 } })
await check('keine_cookies_storage', async (page) => {
await mockApi(page, { total: 8 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const s = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length }))
assert(s.c === '' && s.l === 0 && s.s === 0, JSON.stringify(s))
})
await check('signatur_optional', async (page) => {
await mockApi(page, { total: 2 })
await page.route('**/embed/news-demo.html', async (route) => {
const r = await route.fetch()
await route.fulfill({ response: r, body: (await r.text()).replace('data-webklar-news="abcdefghijklmnop"', 'data-webklar-news="abcdefghijklmnop" data-signature="Opa Martin"') })
})
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-sig').count()) > 0, 'Signatur fehlt')
assert((await page.locator('.wkn-sig').first().innerText()) === 'Opa Martin', 'Signaturtext')
})
await check('ohne_titel_keine_ueberschrift', async (page) => {
await mockApi(page, { custom: [{ id: 'n', type: 'note', title: '', text: 'Nur Text', paper: 'sky', date: '2026-09-29', image: null }] })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
assert((await page.locator('.wkn-pin h3').count()) === 0, 'leere Ueberschrift')
assert((await page.locator('.wkn-sig').count()) === 0, 'Signatur ohne Attribut')
})
await check('seiten_css_ueberschreibt_variablen', async (page) => {
await mockApi(page, { total: 1 })
await page.route('**/embed/news-demo.html', async (route) => {
const r = await route.fetch()
await route.fulfill({ response: r, body: (await r.text()).replace('</head>', '<style>[data-webklar-news]{--wkn-frame:rgb(10, 20, 30)}</style></head>') })
})
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const c = await page.evaluate(() => getComputedStyle(document.querySelector('.wkn-board')).borderTopColor)
assert(c === 'rgb(10, 20, 30)', 'Rahmenfarbe nicht ueberschrieben: ' + c)
})
await check('combo_notiz_nie_schmal', async (page) => {
await mockApi(page, { total: 6 })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
const w = await page.evaluate(() => [...document.querySelectorAll('.wkn-combo')].map((c) => ({ card: c.clientWidth, note: c.querySelector('.wkn-note').clientWidth, stacked: getComputedStyle(c.querySelector('.wkn-combo-in')).gridTemplateColumns.split(' ').length === 1 })))
assert(w.length > 0, 'kein Combo')
for (const x of w) assert(x.stacked ? true : x.note >= 140, 'Notiz zu schmal: ' + JSON.stringify(x))
})
// Screenshots
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
for (const n of [1, 8]) {
const ctx = await browser.newContext({ viewport })
const page = await ctx.newPage()
await mockApi(page, { total: n })
await page.goto(DEMO)
await page.locator('.wkn-pin').first().waitFor()
await page.waitForLoadState('networkidle')
await page.screenshot({ path: `${SHOTS}/embed-${label}-${n}.png`, fullPage: true })
await ctx.close()
}
}
await browser.close()
server.close()
const failed = results.filter(([, ok]) => !ok)
console.log(`${results.length - failed.length}/${results.length} Checks bestanden`)
process.exit(failed.length ? 1 : 0)
+238
View File
@@ -0,0 +1,238 @@
// Playwright-Checks Funktionen-Tab und News-Editor: statischer Server fuer public/ + per page.route gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/funktionen.ui.mjs
// Screenshots der Hauptansichten landen in /root/reports/funktionen-news-ui/.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/funktionen-news-ui'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
// ---- Fixtures ----
const feat = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: state === 'off' ? null : '1234', orderedAt: state === 'off' ? null : '2026-09-28T09:00:00.000Z', activatedAt: state === 'active' ? '2026-09-29T09:00:00.000Z' : null, invoiceUrl: state === 'off' ? null : 'https://example.test/rechnung', ...extra })
const proj = (id, name, state) => ({ projectId: id, projectName: name, liveUrl: 'https://baeckerei.example', features: { news: feat(state) } })
const SCENARIOS = {
off: [proj('p1', 'Bäckerei Berg', 'off')],
setup: [proj('p1', 'Bäckerei Berg', 'setup')],
active: [proj('p1', 'Bäckerei Berg', 'active')],
disabled: [proj('p1', 'Bäckerei Berg', 'disabled')],
multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')],
}
const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.'
const post = (id, type, title, text, paper, extra = {}) => ({ $id: id, type, title, text, paper, duration: '1m', imageId: type === 'note' ? null : `img-${id}`, visibleFrom: '2026-09-25T10:00:00.000Z', visibleUntil: '2026-10-25T21:59:59.999Z', takenDownAt: null, ...extra })
const newsList = {
hanging: [post('n1', 'combo', 'Danke für eure Spenden!', 'Diese Woche sind drei volle Kisten zusammengekommen.', 'sun'), post('n2', 'note', 'Freitag geschlossen', 'Am 3. Oktober bleibt der Laden zu.', 'sky')],
expired: [post('n3', 'photo', 'Holzspielzeug', '', 'white', { visibleUntil: '2026-09-20T21:59:59.999Z' })],
liveUrl: 'https://baeckerei.example',
}
const tickets = [1, 2, 3, 4].map((n) => ({
number: String(1200 + n), topic: `Auftrag ${n}`, createdAt: `2026-09-${10 + n}T10:00:00.000Z`,
status: { step: 4, tone: 'done', label: 'Erledigt' },
entries: [{ kind: 'update', label: 'Änderung', date: '2026-09-20', person: 'Kenso', text: 'Text angepasst' }],
}))
let scenario = 'off'
const checkoutCalls = []
async function mockApi(page) {
await page.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = url.pathname
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/activity') return json({ tickets })
if (p === '/api/funktionen') return json({ projects: SCENARIOS[scenario], setupEstimate: 'meist innerhalb von 1–2 Wochen', consentText: CONSENT })
if (p === '/api/funktionen/news/checkout') {
checkoutCalls.push(req.postDataJSON())
return json({ url: `${base}/checkout-fertig.html` })
}
if (p === '/api/news' && req.method() === 'GET') return json(newsList)
if (p.startsWith('/api/news/image/')) return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#e8b96a"/></svg>' })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
})
}
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const hScroll = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
const browser = await chromium.launch()
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const phone = label === 'phone'
checkoutCalls.length = 0
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await mockApi(page)
const open = async (hash, sc = scenario) => {
scenario = sc
await page.goto('about:blank') // sonst laedt ein reiner Hash-Wechsel nicht neu
await page.goto(`${base}/dashboard.html${hash}`)
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
}
// Einblend-Animation abwarten; Fenster auf Seitenhoehe ziehen, damit feste Leisten (Tab-Leiste) am Rand bleiben
const shot = async (name) => {
await page.waitForTimeout(900)
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 2600))
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
await page.waitForTimeout(150)
await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
await page.setViewportSize(viewport)
}
const noScroll = async (name) => check(`${label} kein_horizontaler_scroll ${name}`, await hScroll(page))
// Projekte + Fortschritt-Sektion
await open('')
await page.waitForSelector('#pj-activity .pd-order-card')
const labels = await page.$$eval('.kb-nav-item .kb-tab-label', (els) => els.map((e) => e.textContent.trim()))
check(`${label} nav_ohne_fortschritt`, labels.join(', ') === 'Projekte, Funktionen, Rechnungen, Chat, Verträge', labels.join(', '))
const shown = await page.$$eval('#pj-activity .pd-order-card', (els) => els.length)
const more = await page.$('#pj-activity [data-activity-all]')
check(`${label} projekte_drei_neueste`, shown === 3 && Boolean(more), `Karten ${shown}`)
await noScroll('projekte')
await shot('1-projekte')
await open('#fortschritt')
await page.waitForSelector('#pj-activity .pd-order-card')
await page.waitForTimeout(900)
const fort = await page.evaluate(() => {
const r = document.getElementById('pj-activity').getBoundingClientRect()
return {
inView: r.top < window.innerHeight && r.bottom > 0,
projectsActive: document.getElementById('view-projects').classList.contains('active'),
navActive: document.querySelector('.kb-nav-item.active')?.dataset.view,
}
})
check(`${label} fortschritt_link`, fort.inView && fort.projectsActive && fort.navActive === 'projects', JSON.stringify(fort))
// Funktionen-Kacheln
const tiles = {}
for (const [sc, pill, btn, shotName] of [['off', 'Aus', 'Einrichten lassen · 30 €', '2-funktionen-aus'], ['setup', 'In Einrichtung', 'Status ansehen', '3-funktionen-einrichtung'], ['active', 'Aktiv', 'Beiträge schreiben', '4-funktionen-aktiv']]) {
await open('#funktionen', sc)
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
const got = await page.evaluate(() => ({
pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(),
btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim(),
soon: [...document.querySelectorAll('.fn-tile:not([data-fn-tile="news"]) .fn-pill')].map((e) => e.textContent.trim()),
}))
tiles[sc] = got
check(`${label} kacheln ${sc}`, got.pill === pill && got.btn === btn && got.soon.length === 2 && got.soon.every((t) => t === 'Kommt bald'), JSON.stringify(got))
await noScroll(`funktionen-${sc}`)
await shot(shotName)
}
await open('#funktionen', 'multi')
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
const multi = await page.textContent('[data-fn-tile="news"]')
check(`${label} kachel_mehrere_projekte`, multi.includes('aktiv für 1 von 2 Websites'))
// Buchung
await open('#funktionen', 'off')
await page.click('[data-fn-tile="news"] .fn-tile-cta')
await page.waitForSelector('#news-order')
const bookText = await page.textContent('#view-news')
check(`${label} buchung_texte`, bookText.includes('meist innerhalb von 1–2 Wochen') && bookText.includes(CONSENT) && bookText.includes('Leistungsbeschreibung'))
check(`${label} bestellen_braucht_haken`, await page.isDisabled('#news-order') && (await page.textContent('#news-order')).trim() === 'Zahlungspflichtig bestellen · 30 €')
await noScroll('news-buchung')
await shot('5-news-buchung')
await page.check('#news-consent')
check(`${label} bestellen_haken_aktiv`, !(await page.isDisabled('#news-order')))
await page.click('#news-order')
await page.waitForURL('**/checkout-fertig.html')
check(`${label} bestellen_checkout`, checkoutCalls.length === 1 && checkoutCalls[0].projectId === 'p1' && checkoutCalls[0].consent === true, JSON.stringify(checkoutCalls))
// Rueckkehr von Stripe
await open('#news?bestellt=1', 'setup')
await page.waitForSelector('#view-news .fn-status')
const back = await page.textContent('#view-news')
check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen'))
check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active')))
check(`${label} bestellt_flag_aus_hash`, await page.evaluate(() => !window.location.hash.includes('bestellt')), await page.evaluate(() => window.location.hash))
// Flag ohne bestellte Funktion (Zustand off): kein Dankeshinweis
await open('#news?bestellt=1', 'off')
await page.waitForSelector('#news-order')
check(`${label} bestellt_flag_ignoriert_bei_off`, !(await page.textContent('#view-news')).includes('Danke!'))
// Abgeschaltet: Pille und Knopf der Kachel, Status statt Buchung
await open('#funktionen', 'disabled')
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
const dis = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim() }))
check(`${label} kachel_abgeschaltet`, dis.pill === 'Abgeschaltet' && dis.btn === 'Status ansehen', JSON.stringify(dis))
check(`${label} news_ohne_aria_live`, await page.evaluate(() => !document.getElementById('view-news').hasAttribute('aria-live')))
// Editor
await open('#news', 'active')
await page.waitForSelector('#nw-title')
check(`${label} editor_dauer_vorauswahl`, (await page.inputValue('#nw-duration')) === '1m')
await page.fill('#nw-title', 'Neue Winterjacken')
await page.waitForTimeout(50)
const prev = await page.textContent('#nw-preview')
check(`${label} editor_vorschau_live`, prev.includes('Neue Winterjacken'))
await page.click('[data-type="note"]')
check(`${label} editor_text_ohne_foto`, !(await page.isVisible('#nw-photo-field')))
await page.click('[data-type="combo"]')
check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field'))
const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`))
check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; '))
// Zurueck zum Tab und wieder in News (gleiches Projekt, gleicher Zustand): Entwurf bleibt stehen
await page.fill('#nw-title', 'Entwurf bleibt')
await page.evaluate(() => document.querySelector('.kb-nav-item[data-view="funktionen"]').click())
await page.waitForSelector('[data-fn-tile="news"] .fn-tile-cta')
await page.click('[data-fn-tile="news"] .fn-tile-cta')
await page.waitForSelector('#nw-title')
await page.waitForTimeout(200)
check(`${label} editor_entwurf_bleibt`, (await page.inputValue('#nw-title')) === 'Entwurf bleibt')
await page.fill('#nw-title', 'Neue Winterjacken')
const lists = await page.textContent('#view-news')
check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen'))
await page.click('[data-del]')
check(`${label} editor_loeschen_rueckfrage`, await page.isVisible('.nw-confirm'))
await page.click('.nw-confirm [data-cancel]')
await noScroll('editor')
await shot('6-news-editor')
if (phone) {
await open('#funktionen', 'active')
await page.click('[data-fn-tile="news"] .fn-tile-cta')
await page.waitForSelector('#nw-title')
const back = await page.evaluate(() => {
const b = document.getElementById('kb-back')
return { hidden: b.classList.contains('hidden'), label: b.getAttribute('aria-label'), tab: document.querySelector('.kb-nav-item.active')?.dataset.view }
})
check('phone editor_handy_unterseite', !back.hidden && back.label === 'Zurück zu Funktionen' && back.tab === 'funktionen', JSON.stringify(back))
}
check(`${label} keine_seitenfehler`, errors.length === 0, errors.join(' | '))
await ctx.close()
}
} finally {
await browser.close()
server.close()
}
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'Alle Checks bestanden')
process.exit(failed ? 1 : 0)