Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7f73abba | ||
|
|
0dc57fe5e8 | ||
|
|
0e0776b406 | ||
|
|
035d1d5517 | ||
|
|
5a09d060a1 | ||
|
|
6dac6aa073 | ||
|
|
a421308019 | ||
|
|
319f8937aa | ||
|
|
d320730251 | ||
|
|
434bab0b7a | ||
|
|
ad0b08f32a | ||
|
|
ac7375e889 | ||
|
|
8374a570aa | ||
|
|
0cc35bf1a5 | ||
|
|
770cbe32b7 | ||
|
|
4b1df8fbfa | ||
|
|
16fbb03c13 | ||
|
|
85bee3190c | ||
|
|
c55e6f2dfe | ||
|
|
586e3cc85d |
No files matched your search
@@ -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.)
|
||||
Generated
+596
@@ -16,6 +16,7 @@
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^19.3.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -46,6 +47,529 @@
|
||||
"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": {
|
||||
"version": "7.0.15",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -741,6 +1274,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -792,6 +1337,50 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -902,6 +1491,13 @@
|
||||
"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": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^19.3.0"
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,81 @@ function renderInvoices(data) {
|
||||
</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) {
|
||||
const existing = document.getElementById(`detail-${customerId}`)
|
||||
if (existing) {
|
||||
@@ -206,6 +281,10 @@ async function toggleCustomerDetail(customerId, kind, cell) {
|
||||
if (kind === 'activity') {
|
||||
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
|
||||
tr.innerHTML = `<td colspan="7"><h4>Tickets & 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 {
|
||||
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
|
||||
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>` : ''}
|
||||
<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-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-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
|
||||
</div>
|
||||
@@ -258,6 +338,8 @@ async function loadCustomers(search = '') {
|
||||
btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs)))
|
||||
tbody.querySelectorAll('[data-activity]').forEach((btn) =>
|
||||
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) =>
|
||||
btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices')))
|
||||
tbody.querySelectorAll('[data-offer-for]').forEach((btn) =>
|
||||
|
||||
+52
-16
@@ -137,7 +137,7 @@ function projectState(project) {
|
||||
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) {
|
||||
const li = document.createElement('li')
|
||||
@@ -146,7 +146,7 @@ function renderProjectCard(project, features) {
|
||||
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
|
||||
const [stateClass, stateText] = projectState(project)
|
||||
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))
|
||||
|
||||
li.innerHTML = `
|
||||
@@ -210,7 +210,7 @@ function renderProjectCard(project, features) {
|
||||
/* --------------------------------------------------------- Projektdetails --- */
|
||||
// Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine
|
||||
// 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 = {
|
||||
update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>',
|
||||
@@ -690,23 +690,34 @@ async function loadCustomerInvoices() {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Aktivität --- */
|
||||
// Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten
|
||||
// (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server)
|
||||
// Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste
|
||||
// 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() {
|
||||
const body = document.getElementById('activity-body')
|
||||
if (!body) return
|
||||
try {
|
||||
const { tickets } = await api('/api/activity')
|
||||
if (!tickets || !tickets.length) {
|
||||
const { tickets: all } = await api('/api/activity')
|
||||
if (!all || !all.length) {
|
||||
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
|
||||
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)}">
|
||||
${renderOrder(t)}
|
||||
${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)
|
||||
body.querySelector('[data-activity-all]')?.addEventListener('click', () => draw(!showAll))
|
||||
}
|
||||
draw(false)
|
||||
} catch (err) {
|
||||
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).
|
||||
// Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES).
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild).
|
||||
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' }
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "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 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()
|
||||
|
||||
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() {
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const hash = currentHash()
|
||||
if (hash === 'fortschritt') return 'projects'
|
||||
if (HASH_ALIASES[hash]) return HASH_ALIASES[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)
|
||||
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') {
|
||||
api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||||
updateChatBadge(0)
|
||||
@@ -2073,10 +2106,12 @@ async function initDashboardPage() {
|
||||
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
|
||||
loadContracts({ afterCheckout: aboParam === 'success' })
|
||||
loadCustomerInvoices()
|
||||
activityScrollPending = currentHash() === 'fortschritt'
|
||||
loadCustomerActivity().then(scrollToActivity)
|
||||
|
||||
const initialView = viewFromHash()
|
||||
// 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([
|
||||
api('/api/projects'),
|
||||
@@ -2098,6 +2133,7 @@ async function initDashboardPage() {
|
||||
} else {
|
||||
empty.classList.remove('hidden')
|
||||
}
|
||||
scrollToActivity()
|
||||
|
||||
// Chat-Badge global pollen (auch wenn der Chat nicht offen ist)
|
||||
pollChatBadge()
|
||||
@@ -2121,8 +2157,8 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "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_LABEL = { vertraege: 'Verträge', projects: 'Projekte', activity: 'Fortschritt', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
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', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
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])'
|
||||
|
||||
|
||||
+24
-8
@@ -21,6 +21,7 @@
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<link rel="stylesheet" href="/kundenbereich.css">
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -99,9 +100,9 @@
|
||||
<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>
|
||||
<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">
|
||||
<svg class="kb-tab-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l3-7 4 14 3-7h4"/></svg>
|
||||
<span class="kb-tab-label">Fortschritt</span></button>
|
||||
<button type="button" class="kb-nav-item" data-view="funktionen">
|
||||
<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">Funktionen</span></button>
|
||||
<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>
|
||||
<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>
|
||||
<ul id="projects" class="kb-projects hidden"></ul>
|
||||
<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>
|
||||
|
||||
<!-- App-Ansicht: Projektdetails als eigene Seite mit Zurueck zu "Projekte" (app.js, openProjectPage);
|
||||
@@ -151,13 +158,22 @@
|
||||
<div class="kb-details pd-page"></div>
|
||||
</section>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<section class="kb-view" id="view-activity">
|
||||
<!-- Funktionen: Kacheln (funktionen.js, initFunktionen); News ist auf dem Handy eine Unterseite davon -->
|
||||
<section class="kb-view" id="view-funktionen">
|
||||
<header class="kb-head">
|
||||
<h1>Fortschritt</h1>
|
||||
<p>Was wir an deinen Projekten erledigt haben.</p>
|
||||
<h1>Funktionen</h1>
|
||||
<p>Zusätzliche Bausteine für deine Website – ein Klick, wir richten sie ein.</p>
|
||||
</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>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
|
||||
@@ -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>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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})()
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
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>
|
||||
@@ -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="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="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
|
||||
</fieldset>
|
||||
|
||||
<label for="name" data-label-for="name" data-label="Name">Name</label>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 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").
|
||||
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
|
||||
* 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
|
||||
await ensure('worksheets.countsToPlan', () =>
|
||||
aw(`${col('worksheets')}/attributes/boolean`, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
|
||||
* 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 { 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'
|
||||
|
||||
export function parseArgs(argv) {
|
||||
@@ -28,7 +28,9 @@ export function parseArgs(argv) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -42,7 +44,9 @@ async function checkPrice(key) {
|
||||
if (!price) return { ok: false, note: `Preis ${catalog.lookupKey} fehlt bei Stripe` }
|
||||
const mismatches = []
|
||||
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"`)
|
||||
// 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
|
||||
|
||||
@@ -88,6 +88,16 @@ const mailStorage = {
|
||||
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 = {
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
sessionSecret: process.env.SESSION_SECRET || '',
|
||||
@@ -118,6 +128,7 @@ export const config = {
|
||||
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
||||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||||
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(/\/$/, ''),
|
||||
@@ -132,6 +143,14 @@ export const config = {
|
||||
|
||||
products,
|
||||
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.
|
||||
// Die Werte kommen aus dem Produktkatalog (products.wartung).
|
||||
@@ -201,6 +220,11 @@ export const config = {
|
||||
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/
|
||||
// (gitignored, ueberleben Deploys wie preview-data/)
|
||||
chat: {
|
||||
|
||||
@@ -12,9 +12,12 @@ import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import chatRoutes from './routes/chat.js'
|
||||
import aboRoutes from './routes/abo.js'
|
||||
import funktionenRoutes from './routes/funktionen.js'
|
||||
import takeoverCreditRoutes from './routes/takeoverCredits.js'
|
||||
import { purgeExpiredUploads } from './services/takeoverCredits.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 pushTimeRoutes from './routes/pushTime.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 adminInvoiceClientsRoutes from './routes/admin/invoiceClients.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'
|
||||
|
||||
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/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api/public/news', publicNewsRoutes)
|
||||
app.use('/api/news', newsRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
|
||||
app.use('/api/abo', aboRoutes)
|
||||
app.use('/api/funktionen', funktionenRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
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.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))
|
||||
setTimeout(purgeChat, 90 * 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) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: E-Mail aus dem Formular · BCC intern
|
||||
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).
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -50,6 +50,9 @@ a{color:#111111;}
|
||||
<!-- 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>
|
||||
<!-- 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 -->
|
||||
<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 -->
|
||||
|
||||
@@ -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. ͏ ͏ ͏</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;">✓ 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 →</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 →</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. ͏ ͏ ͏</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;">✓ 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 →</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>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
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="color-scheme" content="light dark">
|
||||
@@ -60,6 +60,9 @@ a{color:#111111;}
|
||||
<!-- 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>
|
||||
<!-- 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 -->
|
||||
<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 -->
|
||||
|
||||
@@ -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}}. ͏ ͏ ͏</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;"> </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
@@ -18,6 +18,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} 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 {
|
||||
ACTIVE_STATUSES,
|
||||
@@ -518,6 +519,11 @@ async function recordAbschluss(sub, session) {
|
||||
|
||||
async function handleStripeEvent(event) {
|
||||
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) {
|
||||
case 'checkout.session.completed': {
|
||||
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
|
||||
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)
|
||||
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.
|
||||
* 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).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -776,6 +782,7 @@ function contractLabel(contract, sub) {
|
||||
function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
const abo = productNameOf(sub)
|
||||
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. */
|
||||
function describeWithdrawn(events) {
|
||||
return events
|
||||
@@ -1079,12 +1105,20 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
|
||||
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 }
|
||||
// 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 {
|
||||
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)
|
||||
if (sub) {
|
||||
result = await withdrawOrder(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
|
||||
}
|
||||
@@ -1097,7 +1131,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
produkt: productLabelOf(sub),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
@@ -1107,7 +1141,8 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
pruefen: !inTime,
|
||||
funktion: feature,
|
||||
pruefen: !inTime && !feature,
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
@@ -1119,11 +1154,19 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
produkt: productLabelOf(sub),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
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)'
|
||||
: inTime
|
||||
? 'RECHTZEITIG'
|
||||
@@ -1136,18 +1179,19 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
: result.deadline
|
||||
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
|
||||
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
|
||||
ereignisse: describeWithdrawn(result.withdrawn),
|
||||
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
|
||||
pruefhinweis,
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abo_gefunden: Boolean(sub),
|
||||
auf_kuendigung_gesetzt: result.canceled,
|
||||
kein_abo_gefunden: !sub,
|
||||
kein_abo_gefunden: !sub && !feature,
|
||||
kuendigung_fehlgeschlagen: result.cancelFailed,
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
funktion: feature,
|
||||
keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn,
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
wechsel_offen: result.openSwitchWithdrawn,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -43,7 +43,7 @@ const waitingUploads = []
|
||||
function startWaitingUploads() {
|
||||
while (activeUploads < config.chat.maxParallelUploads && waitingUploads.length) waitingUploads.shift()()
|
||||
}
|
||||
function uploadSlot(_req, res, next) {
|
||||
export function uploadSlot(_req, res, next) {
|
||||
let state = 'waiting'
|
||||
// 'close' kommt nach der Antwort oder wenn der Browser abbricht - auch waehrend des Wartens
|
||||
res.once('close', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ router.get('/', requireSession, async (req, res) => {
|
||||
projectId: f.projectId || '',
|
||||
featureKey: f.featureKey || '',
|
||||
enabled: Boolean(f.enabled),
|
||||
state: f.state || (f.enabled ? 'active' : 'off'),
|
||||
unlockedByPurchase: Boolean(f.unlockedByPurchase),
|
||||
purchaseStatus: f.purchaseStatus || '',
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
storeChatUpload,
|
||||
uploadRateLimited,
|
||||
} from '../services/chatAttachments.js'
|
||||
import { funktionenAdminRouter } from './admin/funktionenAdmin.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { storageTasksAdminRouter } from './storageTasks.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
@@ -40,6 +41,8 @@ router.use('/takeover-credits', takeoverAdminRouter)
|
||||
router.use('/offers', offersAdminRouter)
|
||||
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
|
||||
router.use('/storage-tasks', storageTasksAdminRouter)
|
||||
// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen")
|
||||
router.use('/funktionen', funktionenAdminRouter)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,7 @@ const wallFormat = new Intl.DateTimeFormat('en-US', {
|
||||
})
|
||||
|
||||
// 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
|
||||
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). */
|
||||
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 wall = Date.UTC(y, mo - 1, d, hour, minute, second, ms)
|
||||
const guess = wall - berlinOffset(wall)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../config.js'
|
||||
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { deleteNewsImage } from './newsImages.js'
|
||||
import { disable } from './siteFeatures.js'
|
||||
import { getStripe } from './stripeBilling.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
|
||||
* 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.
|
||||
* - 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.
|
||||
* 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
|
||||
@@ -25,6 +30,8 @@ import { filesOf, STATUS } from './takeoverCredits.js'
|
||||
const SUBSCRIPTIONS = () => config.collections.subscriptions
|
||||
const EVENTS = () => config.collections.contractEvents
|
||||
const TAKEOVER = () => config.collections.takeoverCredits
|
||||
const NEWS_POSTS = () => config.collections.newsPosts
|
||||
const FEATURES = () => config.collections.portalFeatures
|
||||
|
||||
export const RETENTION_YEARS = 3
|
||||
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) })
|
||||
}
|
||||
|
||||
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 rejectedCutoff = now.getTime() - config.takeover.rejectedRetentionDays * 86400000
|
||||
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`,
|
||||
})
|
||||
}
|
||||
return { customers, takeover, pending, skipped }
|
||||
return { customers, news, takeover, pending, skipped }
|
||||
}
|
||||
|
||||
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
|
||||
* 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.
|
||||
* @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 }[],
|
||||
* failed: { what: string, id: string, reason: string }[] }>}
|
||||
*/
|
||||
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 failed = []
|
||||
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')) {
|
||||
const removed = await attempt('takeoverCredits', item.id, async () => {
|
||||
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)`
|
||||
)
|
||||
}
|
||||
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 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}`))
|
||||
@@ -220,7 +266,8 @@ export function formatReport(plan, result = null) {
|
||||
const d = result.deleted
|
||||
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) {
|
||||
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,17 +51,19 @@ export function isStripeConfigured() {
|
||||
}
|
||||
|
||||
// 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. */
|
||||
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()
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function ensurePrice(key) {
|
||||
@@ -94,7 +96,7 @@ export async function ensurePrice(key) {
|
||||
product: product.id,
|
||||
currency: 'eur',
|
||||
unit_amount: catalog.amountCents,
|
||||
recurring: { interval: 'month' },
|
||||
...(catalog.oneTime ? {} : { recurring: { interval: 'month' } }),
|
||||
tax_behavior: 'inclusive',
|
||||
lookup_key: catalog.lookupKey,
|
||||
nickname: catalog.priceNickname,
|
||||
@@ -589,6 +591,53 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
|
||||
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) {
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
if (!sub?.stripeCustomerId) {
|
||||
|
||||
@@ -65,3 +65,43 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) {
|
||||
console.log(`[ticketAdmin] Webpage-Ticket #${woid} fuer Projekt ${project.$id} angelegt`)
|
||||
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 }
|
||||
}
|
||||
@@ -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)'))
|
||||
})
|
||||
@@ -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')
|
||||
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')
|
||||
})
|
||||
@@ -6,17 +6,20 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
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);
|
||||
// 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 = [
|
||||
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||||
'K12',
|
||||
'K13',
|
||||
'K14',
|
||||
'K15',
|
||||
'K16',
|
||||
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
|
||||
'T12',
|
||||
'T13',
|
||||
'T14',
|
||||
'T15',
|
||||
'T16',
|
||||
]
|
||||
|
||||
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]
|
||||
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)
|
||||
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]))
|
||||
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 b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
|
||||
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 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 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 speicher = renderMail('K03', k03, { zusatzspeicher: true })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -452,6 +452,31 @@ test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerr
|
||||
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 () => {
|
||||
const startedAt = daysAgo(2)
|
||||
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="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
|
||||
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.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
@@ -15,11 +15,14 @@ const aw = await startAppwriteMock({
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
takeoverCredits: schemaKeys('takeoverCredits'),
|
||||
newsPosts: schemaKeys('newsPosts'),
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-'))
|
||||
process.env.TAKEOVER_UPLOAD_DIR = uploadDir
|
||||
process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news')
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js')
|
||||
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.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')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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.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)
|
||||
})
|
||||
@@ -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)
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
@@ -314,10 +314,22 @@ export async function startFakeStripe() {
|
||||
subscription: null,
|
||||
metadata: data.metadata || {},
|
||||
subscription_data: data.subscription_data || {},
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
invoice: null,
|
||||
}
|
||||
checkoutSessions.set(id, 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') {
|
||||
const active = query.get('active')
|
||||
const found = [...billingPortalConfigurations.values()].filter(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user