diff --git a/docs/rechtliches/webshop-kanzlei-paket.md b/docs/rechtliches/webshop-kanzlei-paket.md index 8f78531..3f57fd7 100644 --- a/docs/rechtliches/webshop-kanzlei-paket.md +++ b/docs/rechtliches/webshop-kanzlei-paket.md @@ -373,7 +373,7 @@ Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihe ### 7.3 Technik -- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (vier Collections) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy. +- [ ] `node scripts/shop-setup.mjs --check`, dann `node scripts/shop-setup.mjs` (fünf Collections, darunter das Archiv der bestätigten Rechtstexte) und `node scripts/stripe-products.mjs --anlegen` (Produkt `shopSetup`), vor dem Code-Deploy. - [ ] Connect-Webhook in Stripe anlegen (Ereignisse: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `account.updated`, `charge.refunded`), Geheimnis als `STRIPE_CONNECT_WEBHOOK_SECRET` in `/home/webklar/apps/.env`, Zeilen `SHOP_ENABLED` und `SHOP_PILOT_EMAILS` in der Compose-Datei. - [ ] Code nach `main` (nach dem Terminplaner), WOMS-Zweig nach `test`, Deploy zu einer ruhigen Zeit. - [ ] PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` (nicht `all`), PDFs des Webshops mit `scripts/build-legal-pdfs.sh shop`; Prüfsummen mit den Tests abgleichen. diff --git a/public/shop-tab-rechtliches.js b/public/shop-tab-rechtliches.js index f4167f3..d1a46f7 100644 --- a/public/shop-tab-rechtliches.js +++ b/public/shop-tab-rechtliches.js @@ -18,9 +18,9 @@ export const NICHT_BESTAETIGT = 'Änderungen noch nicht bestätigt' export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.' const TEXTE = [ - { key: 'agb', label: 'AGB', zeilen: 14, max: 30000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an.' }, - { key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 20000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt.' }, - { key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 5000, hinweis: null }, + { key: 'agb', label: 'AGB', zeilen: 14, max: 20000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an. Höchstens 20.000 Zeichen: Der Text steht vollständig in der Bestätigungs-E-Mail an deine Kunden.' }, + { key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 12000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt. Höchstens 12.000 Zeichen. Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten; das Muster tut das.' }, + { key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 3000, hinweis: 'Höchstens 3.000 Zeichen.' }, ] const ADRESSEN = [ { key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' }, diff --git a/scripts/shop-setup.mjs b/scripts/shop-setup.mjs index 7ba7a65..27ae94b 100644 --- a/scripts/shop-setup.mjs +++ b/scripts/shop-setup.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Webshop: legt die vier Collections shopConfig, shopProducts, shopOrders und shopWiderrufe mit + * Webshop: legt die fuenf Collections shopConfig, shopProducts, shopOrders, shopWiderrufe und shopRechtstexteArchiv mit * Attributen und Indizes in Appwrite an (nur Server-Key, keine Client-Rechte). Das Schema steht in * server/services/shop/schema.js. Idempotent: Vorhandenes wird uebersprungen, fehlende Attribute und Indizes * auch in schon vorhandenen Collections nachgezogen. Vor dem Einschalten (SHOP_ENABLED) ausfuehren. diff --git a/server/config.js b/server/config.js index b0c69fc..44d2681 100644 --- a/server/config.js +++ b/server/config.js @@ -537,11 +537,12 @@ export const config = { termineBookings: process.env.APPWRITE_COLLECTION_TERMINE_BOOKINGS || 'termineBookings', termineClaims: process.env.APPWRITE_COLLECTION_TERMINE_CLAIMS || 'termineClaims', termineOutbox: process.env.APPWRITE_COLLECTION_TERMINE_OUTBOX || 'termineOutbox', - // Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe; scripts/shop-setup.mjs) + // Webshop (Einstellungen, Artikel, Bestellungen, Widerrufe, Archiv der bestaetigten Rechtstexte; scripts/shop-setup.mjs) shopConfig: process.env.APPWRITE_COLLECTION_SHOP_CONFIG || 'shopConfig', shopProducts: process.env.APPWRITE_COLLECTION_SHOP_PRODUCTS || 'shopProducts', shopOrders: process.env.APPWRITE_COLLECTION_SHOP_ORDERS || 'shopOrders', shopWiderrufe: process.env.APPWRITE_COLLECTION_SHOP_WIDERRUFE || 'shopWiderrufe', + shopRechtstexteArchiv: process.env.APPWRITE_COLLECTION_SHOP_RECHTSTEXTE_ARCHIV || 'shopRechtstexteArchiv', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), diff --git a/server/routes/shop.js b/server/routes/shop.js index 7b2bdbb..ff38433 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -17,6 +17,7 @@ import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/sh import { bildIdsInGebrauch, holeProdukt, leseBestandGeladen, listeProdukte, loescheProdukt, normalisiereProdukt, parseProdukt, sortiere, speichereProdukt, } from '../services/shop/produkte.js' +import { archiviereRechtstexte } from '../services/shop/archiv.js' import { BESTAETIGUNG_TEXT, GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, musterAgb, musterWiderrufsbelehrung, musterWiderrufsformular, normalisiereRechtstexte, pruefeBestaetigbar, rechtstexteHash, versandUndZahlung, vollstaendig, @@ -434,6 +435,15 @@ router.post('/rechtstexte/bestaetigen', requireSession, nurKunde, limitWrites, r } const at = new Date().toISOString() const bestaetigt = { texte: { ...entwurf }, hash, at, von } + // Fassung ins Archiv (G09 zeigt dem Kaeufer die Fassung seiner Bestellung); scheitert das, wird nichts bestaetigt + await archiviereRechtstexte({ + shopId: shop.id, + customerId: shop.customerId, + hash, + texte: { ...entwurf, versandUndZahlung: versandUndZahlung(aktuell.einstellungen), gewaehrleistung: gewaehrleistungsHinweis() }, + at, + von, + }) const json = JSON.stringify({ entwurf, bestaetigt }) if (json.length > PRODUKT_DOC_MAX_JSON) throw stop(400, 'Die Rechtstexte sind zu umfangreich. Bitte kürze die Texte.') await updateDocument(config.collections.shopConfig, shop.id, { diff --git a/server/services/retention.js b/server/services/retention.js index 3206608..517eff8 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -29,7 +29,7 @@ import { filesOf, STATUS } from './takeoverCredits.js' * - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument * (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren * Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey). - * - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Artikelbilder, shopConfig; + * - Webshop: Fuer faellige Kunden loescht derselbe Lauf auch die Daten ihrer Shops (Bestellungen, Artikel, Widerrufe, Archiv der bestaetigten Rechtstexte, Artikelbilder, shopConfig; * plan.news[].shops, nur wenn es welche gibt). Je Kunde hoechstens SHOP_LOESCH_DECKEL Dokumente je Lauf; bleibt etwas uebrig, wird der * Kunde gehalten (held) und der naechste Lauf macht weiter. Die eigenen Fristen des Shops (Anonymisierung, 90 Tage nach dem * Abschalten) laufen unabhaengig im Tagesjob des Shops (shop/jobs.js). @@ -268,7 +268,7 @@ export async function applyDeletions(plan) { for (const id of entry.consentIds || []) { if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1 } - // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) + // Webshop-Daten: Bestellungen, Artikel, Widerrufe, Rechtstexte-Archiv, Bilder, zuletzt shopConfig (Deckel je Kunde; Rest = der Kunde wird gehalten) let shopRest = SHOP_LOESCH_DECKEL for (const shop of entry.shops || []) { await attempt('shopConfig', shop.id, async () => { diff --git a/server/services/shop/archiv.js b/server/services/shop/archiv.js new file mode 100644 index 0000000..8bae79c --- /dev/null +++ b/server/services/shop/archiv.js @@ -0,0 +1,60 @@ +import { createHash } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, getDocument } from '../appwriteAdmin.js' + +/** + * Archiv der bestaetigten Rechtstexte eines Shops (Review B, I-6). Beim Bestaetigen wird die Fassung samt Hash abgelegt; G09 zeigt dem + * Kaeufer die Fassung zum Hash seiner Bestellung (bestellung.rechtstexteHash), nicht die beim Mailversand aktuelle. So passen Mail und + * Bestellung zusammen, und der Haendler kann spaeter belegen, welche Belehrung ein Kaeufer bekam (§ 356 Abs. 3 BGB). + * Eine Fassung je Dokument, ID = 'r' + die ersten 35 Hex-Zeichen von sha256(shopId + hash); dieselbe Fassung ein zweites Mal + * (409) ist kein Fehler. Inhalt (texte, JSON): agb, widerrufsbelehrung, widerrufsformular, impressumUrl, datenschutzUrl, + * versandUndZahlung, gewaehrleistung. + */ + +const col = () => config.collections.shopRechtstexteArchiv + +/** Dokument-ID der Fassung (hoechstens 36 Zeichen, nur Hex nach dem 'r'). */ +export const archivId = (shopId, hash) => `r${createHash('sha256').update(`${String(shopId)}${String(hash)}`).digest('hex').slice(0, 35)}` + +/** + * Legt die Fassung an, falls es sie noch nicht gibt. + * @param {{ shopId: string, customerId: string, hash: string, texte: object, at: string, von: string }} p + * @returns {Promise<{ angelegt: boolean }>} + */ +export async function archiviereRechtstexte({ shopId, customerId, hash, texte, at, von }) { + if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) throw new TypeError('archiviereRechtstexte: shopId oder hash fehlt') + try { + await createDocument( + col(), + { shopId, customerId: String(customerId ?? ''), hash, texte: JSON.stringify(texte), bestaetigtAt: at, bestaetigtVon: String(von ?? '').slice(0, 255) }, + archivId(shopId, hash), + ) + return { angelegt: true } + } catch (err) { + if (err?.status === 409) return { angelegt: false } + throw err + } +} + +/** + * Die archivierte Fassung zum Hash oder null, wenn es sie nicht gibt (zum Beispiel bei Bestellungen vor der Einfuehrung des Archivs). + * Andere Fehler (Datenbank) werfen, damit G09 nicht mit falschen Texten rausgeht, sondern wiederholt wird. + * @returns {Promise} + */ +export async function holeArchivierteRechtstexte(shopId, hash) { + if (typeof shopId !== 'string' || shopId === '' || typeof hash !== 'string' || hash === '') return null + let doc + try { + doc = await getDocument(col(), archivId(shopId, hash)) + } catch (err) { + if (err?.status === 404) return null + throw err + } + if (doc?.shopId !== shopId || doc?.hash !== hash) return null + try { + const texte = JSON.parse(doc.texte) + return texte !== null && typeof texte === 'object' && !Array.isArray(texte) ? texte : null + } catch { + return null + } +} diff --git a/server/services/shop/loeschen.js b/server/services/shop/loeschen.js index b2d5a3b..32ee4ea 100644 --- a/server/services/shop/loeschen.js +++ b/server/services/shop/loeschen.js @@ -3,7 +3,7 @@ import { deleteDocument, listDocuments, Query } from '../appwriteAdmin.js' import { loescheAlleBilder } from './bilder.js' /** - * Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, Artikelbilder und zuletzt das + * Alle Daten EINES Shops loeschen (Plan 2026-10-05-webshop.md, J3): Bestellungen, Artikel, Widerrufe, das Archiv der bestaetigten Rechtstexte, Artikelbilder und zuletzt das * shopConfig-Dokument. Gemeinsamer Baustein des Tagesjobs (Shop seit 90 Tagen abgeschaltet, jobs.js) und der Loeschfristen * (retention.js, Kunde faellig). Bewusst ohne Import von bestellungen.js/Stripe: retention.js darf nichts Schweres nachladen. * Das Stripe-Konto gehoert dem Haendler und wird nie angefasst. @@ -17,17 +17,18 @@ const GRUPPEN = [ ['shopOrders', 'bestellungen'], ['shopProducts', 'artikel'], ['shopWiderrufe', 'widerrufe'], + ['shopRechtstexteArchiv', 'archiv'], ] /** * @param {{ shopId: string, siteKey?: string, deckel?: number, nochGueltig?: () => Promise }} opt - * deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe); nochGueltig wird vor jeder Seite gefragt (false = abbrechen) - * @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>} + * deckel: hoechstens so viele Dokumente (Bestellungen + Artikel + Widerrufe + Archiv); nochGueltig wird vor jeder Seite gefragt (false = abbrechen) + * @returns {Promise<{ bestellungen: number, artikel: number, widerrufe: number, archiv: number, geloescht: number, konfig: boolean, vollstaendig: boolean, abgebrochen: boolean }>} * vollstaendig = alle Dokumente, Bilder und das shopConfig-Dokument sind weg */ export async function loescheShopDaten({ shopId, siteKey, deckel = Number.POSITIVE_INFINITY, nochGueltig = async () => true }) { if (typeof shopId !== 'string' || shopId === '') throw new TypeError('loescheShopDaten: shopId fehlt') - const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false } + const erg = { bestellungen: 0, artikel: 0, widerrufe: 0, archiv: 0, geloescht: 0, konfig: false, vollstaendig: false, abgebrochen: false } for (const [name, feld] of GRUPPEN) { const sammlung = config.collections[name] for (;;) { diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js index 2d7c99b..1a5442d 100644 --- a/server/services/shop/mails.js +++ b/server/services/shop/mails.js @@ -7,6 +7,7 @@ import { pruefeNow, zeitMs } from '../termine/util.js' import { datumKurz, uhrzeit } from '../termine/zeit.js' import { euro } from './geld.js' import { oeffentlicheRechtstexte } from './kontext.js' +import { holeArchivierteRechtstexte } from './archiv.js' import { gewaehrleistungsHinweis } from './rechtstexte.js' /** @@ -162,16 +163,17 @@ const datumVon = (now, ...zeitpunkte) => { * @param {{ doc?: object, shop: object }} ctx * @param {object|null} order * @param {Date} [now] Ersatzdatum, wenn die Bestellung keinen Zeitpunkt hat + * @param {{ archiv?: object|null }} [optionen] archiv: archivierte Rechtstexte der Bestellung (archiv.js); ohne sie die aktuell bestaetigten * @returns {Record} */ -export function kaeuferWerte(ctx, order, now = new Date()) { +export function kaeuferWerte(ctx, order, now = new Date(), { archiv = null } = {}) { if (!istObjekt(ctx?.shop)) throw new TypeError('kaeuferWerte: ctx.shop fehlt') const snap = schnappschuss(order) const e = einstellungenVon(ctx) const auszug = istObjekt(snap.einstellungenAuszug) ? snap.einstellungenAuszug : {} const anbieter = anbieterVon(ctx, snap) const abholung = { ...(e.abholung ?? {}), ...(istObjekt(auszug.abholung) ? auszug.abholung : {}) } - const rechtstexte = oeffentlicheRechtstexte(ctx) ?? {} + const rechtstexte = (istObjekt(archiv) ? archiv : oeffentlicheRechtstexte(ctx)) ?? {} const summen = istObjekt(snap.summen) ? snap.summen : snap const texte = istObjekt(summen.texte) ? summen.texte : {} const cent = (wert) => (Number.isInteger(wert) ? euro(wert) : LEER) @@ -204,7 +206,7 @@ export function kaeuferWerte(ctx, order, now = new Date()) { agb: oderLeer(rechtstexte.agb), widerrufsbelehrung: oderLeer(rechtstexte.widerrufsbelehrung), widerrufsformular: oderLeer(rechtstexte.widerrufsformular), - gewaehrleistung: gewaehrleistungsHinweis(), + gewaehrleistung: hatText(rechtstexte.gewaehrleistung) ? rechtstexte.gewaehrleistung.trim() : gewaehrleistungsHinweis(), // fuer G11 (Standard, wenn der Haendler keinen Hinweis hinterlegt hat) hinweis: text(abholung.hinweis) || ABHOLUNG_HINWEIS, } @@ -219,8 +221,8 @@ export function kaeuferWerte(ctx, order, now = new Date()) { * G12 name, bestellnummer (Eingaben des Kaeufers; ohne Angabe die der Bestellung), eingang (Date | ISO, Pflicht), nachricht * G13 betragCent (Pflicht, ganze Cent), grund */ -function kaeuferMail(art, ctx, order, extra, now) { - const alle = kaeuferWerte(ctx, order, now) +function kaeuferMail(art, ctx, order, extra, now, archiv = null) { + const alle = kaeuferWerte(ctx, order, now, { archiv }) const blocks = {} switch (art) { case 'G09': @@ -257,38 +259,45 @@ function kaeuferMail(art, ctx, order, extra, now) { return { values, blocks: Object.fromEntries(Object.entries(blocks).filter(([name]) => vorlage.blocks.includes(name))) } } +/** Archivierte Rechtstexte zum Hash der Bestellung (G09); null (mit Warnung) fuer Bestellungen ohne Archiveintrag. Datenbankfehler werfen. */ +async function archivFuer(ctx, order) { + const hash = text(schnappschuss(order).rechtstexteHash) + if (hash === '') return null + const shopId = ctx?.shop?.id || ctx?.doc?.$id + const archiv = await holeArchivierteRechtstexte(shopId, hash) + if (!archiv) console.warn(`[shop-mails] G09: Rechtstexte der Bestellung ${String(order?.$id).slice(0, 36)} fehlen im Archiv, die aktuell bestätigte Fassung wird verwendet`) + return archiv +} + const zuLang = (mail) => mail.html.length > MAX_HTML || mail.text.length > MAX_TEXT // ---------------------------------------------------------------- Einreihen /** * Reiht eine Kaeufermail (G09-G14) in die Warteschlange ein. Empfaenger ist extra.an, sonst order.kaeuferEmail. bookingId ist - * order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". Ist G09 zu lang fuer die Warteschlange - * (AGB, Widerrufsbelehrung und Formular koennen zusammen sehr lang sein), ersetzt der Server den AGB-Text durch einen Satz mit - * Link auf die AGB im Shop; Widerrufsbelehrung, Formular und Gewaehrleistungshinweis bleiben immer vollstaendig. + * order.$id, sonst extra.bookingId (z. B. die ID des Widerrufs bei G12), sonst "-". G09 enthaelt die Rechtstexte in der Fassung der + * Bestellung (archiv.js, Hash bestellung.rechtstexteHash); fehlt sie im Archiv, gilt mit Warnung die aktuell bestaetigte. Ist die + * Mail zu lang fuer die Warteschlange, scheitert sie (ok: false), statt Texte durch einen Link zu ersetzen. * @param {'G09'|'G10'|'G11'|'G12'|'G13'|'G14'} art * @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js) * @param {object|null} order shopOrders-Dokument (bei G12 ohne passende Bestellung null) * @param {object} [extra] siehe kaeuferMail * @param {Date} [now] - * @returns {Promise<{ ok: true, eintrag: object, gekuerzt: boolean } | { ok: false, fehler: string }>} + * @returns {Promise<{ ok: true, eintrag: object } | { ok: false, fehler: string }>} */ export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) { try { if (!GAST_ARTEN.includes(art)) throw new TypeError(`${String(art).slice(0, 20)} ist keine Käufermail`) pruefeNow(now, 'anKaeufer') extra = extra ?? {} - const { values, blocks } = kaeuferMail(art, ctx, order, extra, now) + const archiv = art === 'G09' ? await archivFuer(ctx, order) : null + const { values, blocks } = kaeuferMail(art, ctx, order, extra, now, archiv) const an = hatText(extra.an) ? extra.an.trim() : text(order?.kaeuferEmail) if (an === '') throw new TypeError('Empfänger fehlt') - let gekuerzt = false - let mail = renderMail(art, values, blocks) - if (art === 'G09' && zuLang(mail)) { - values.agb = `Die vollständigen AGB finden Sie unter ${shopSeite(ctx)}?ansicht=agb` - mail = renderMail(art, values, blocks) - gekuerzt = true - } + // Nie ein Link statt der Texte: die Bestaetigung muss den Vertragsinhalt selbst enthalten (§ 312f Abs. 2 BGB). Passt die Mail nicht + // in die Warteschlange, scheitert sie hier; der Aufrufer behaelt den Marker offen, zaehlt den Versuch und wiederholt. + const mail = renderMail(art, values, blocks) if (zuLang(mail)) throw new TypeError('Die Mail ist zu lang für die Warteschlange') const e = einstellungenVon(ctx) @@ -306,7 +315,7 @@ export async function anKaeufer(art, ctx, order, extra = {}, now = new Date()) { }, now, ) - return { ok: true, eintrag, gekuerzt } + return { ok: true, eintrag } } catch (err) { console.warn(`[shop-mails] ${String(art).slice(0, 20)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) return { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' } diff --git a/server/services/shop/rechtstexte.js b/server/services/shop/rechtstexte.js index c87df86..640e712 100644 --- a/server/services/shop/rechtstexte.js +++ b/server/services/shop/rechtstexte.js @@ -14,9 +14,13 @@ import { STANDARD_EINSTELLUNGEN } from './einstellungen.js' export const BESTAETIGUNG_TEXT = 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.' -export const MAX_AGB = 30000 -export const MAX_WIDERRUFSBELEHRUNG = 20000 -export const MAX_WIDERRUFSFORMULAR = 5000 +// Grenzen so gewaehlt, dass die Bestaetigungs-E-Mail G09 mit allen drei Texten in die Warteschlange passt (html 100000, text 50000; +// die Vorlage braucht rund 12000 Zeichen) und der Volltext nie durch einen Link ersetzt werden muss (§ 312f Abs. 2 BGB). +export const MAX_AGB = 20000 +export const MAX_WIDERRUFSBELEHRUNG = 12000 +export const MAX_WIDERRUFSFORMULAR = 3000 +/** So viele Zeichen duerfen die drei Texte zusammen im HTML der Mail belegen (maskierte Zeichen und Zeilenumbrueche zaehlen mehrfach). */ +export const MAX_MAIL_HTML_TEXTE = 60000 const MAX_URL = 500 /** Erste Zeile des vorbefuellten Muster-AGB: der Haendler muss sie nach der Pruefung loeschen. */ export const MUSTER_KOPFZEILE = 'Muster – bitte prüfen und anpassen' @@ -230,6 +234,20 @@ export function gewaehrleistungsHinweis() { ].join('\n\n') } +/** Laenge eines Textes im HTML der Mail (wie mailTemplates.renderMail: & < > \" ' maskiert, jeder Umbruch wird
). */ +export function maskierteLaenge(wert) { + let n = 0 + for (const z of String(wert)) { + if (z === '&') n += 5 + else if (z === '<' || z === '>') n += 4 + else if (z === '"') n += 6 + else if (z === "'") n += 5 + else if (z === '\n') n += 4 + else if (z !== '\r') n += 1 + } + return n +} + // --- Normalisierung -------------------------------------------------------------------------------------------------------- // Steuerzeichen (ausser Zeilenumbruch) und unsichtbare Formatzeichen raus @@ -300,6 +318,10 @@ export function normalisiereRechtstexte(eingabe) { out[key] = '' } else out[key] = r.wert } + // Passt die Bestaetigungs-E-Mail mit allen Texten noch in die Warteschlange? (& < > " ' werden mehrfach lang, jeder Umbruch wird
) + if (Object.keys(fehler).length === 0 && ['agb', 'widerrufsbelehrung', 'widerrufsformular'].reduce((summe, k) => summe + maskierteLaenge(out[k]), 0) > MAX_MAIL_HTML_TEXTE) { + fehler.rechtstexte = 'AGB, Widerrufsbelehrung und Widerrufsformular sind zusammen zu lang für die Bestätigungs-E-Mail an deine Kunden (viele Sonderzeichen und Zeilenumbrüche zählen mehrfach). Bitte kürze die Texte.' + } if (Object.keys(fehler).length) return { fehler } return { rechtstexte: { diff --git a/server/services/shop/schema.js b/server/services/shop/schema.js index aca5665..1f76d0d 100644 --- a/server/services/shop/schema.js +++ b/server/services/shop/schema.js @@ -1,5 +1,5 @@ /** - * Webshop: Appwrite-Schema der vier Collections (Spezifikation 2026-10-05-webshop-design.md). + * Webshop: Appwrite-Schema der fuenf Collections (Spezifikation 2026-10-05-webshop-design.md). * Reine Daten, absichtlich ohne Import von server/config.js: test/support/schema.js liest sie vor useTestEnv(), * ein frueh geladenes config.js wuerde den Endpunkt des Appwrite-Nachbaus verfehlen. Die Collection-IDs stehen in * config.collections.; scripts/shop-setup.mjs legt alles an. Kein Feld heisst data (appwriteAdmin.formatRequestBody @@ -97,6 +97,14 @@ export const SHOP_COLLECTIONS = { ], indexes: [index('idx_shop', 'shopId', 'eingangAt')], }, + + // Archiv der bestaetigten Rechtstexte (eine Fassung je Dokument, ID 'r' + 35 Hex-Zeichen aus sha256(shopId + hash)): G09 zeigt dem + // Kaeufer die Fassung zum Zeitpunkt seiner Bestellung, und der Haendler kann belegen, welche Belehrung galt. Keine personenbezogenen + // Daten ausser der Person, die bestaetigt hat; wird mit dem Shop geloescht (loeschen.js). + shopRechtstexteArchiv: { + attributes: [str('shopId', 36), str('customerId', 36), str('hash', 64), str('texte', 100000), dt('bestaetigtAt'), str('bestaetigtVon', 255)], + indexes: [index('idx_shop_hash', 'shopId', 'hash')], + }, } /** Datumsfelder je Collection, damit der Appwrite-Nachbau der Tests sie wie Appwrite formatieren kann. */ diff --git a/test/retention.test.js b/test/retention.test.js index d4d8998..8570c1c 100644 --- a/test/retention.test.js +++ b/test/retention.test.js @@ -22,6 +22,7 @@ const aw = await startAppwriteMock({ shopProducts: schemaKeys('shopProducts'), shopOrders: schemaKeys('shopOrders'), shopWiderrufe: schemaKeys('shopWiderrufe'), + shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), }, }) const st = await startFakeStripe() @@ -599,6 +600,7 @@ function seedFaelligMitShop(kunde, siteKey = SHOP_KEY) { aw.seed('shopOrders', { $id: `${kunde}_ord1`, shopId, customerId: kunde, bestellnummer: '26-A', status: 'versendet', kaeuferEmail: 'k@example.com', bestellung: '{}' }) aw.seed('shopOrders', { $id: `${kunde}_ord2`, shopId, customerId: kunde, bestellnummer: '26-B', status: 'storniert', kaeuferEmail: 'k@example.com', bestellung: '{}' }) aw.seed('shopWiderrufe', { $id: `${kunde}_wid`, shopId, customerId: kunde, bestellnummer: '26-A', name: 'K', email: 'k@example.com', nachricht: 'x' }) + aw.seed('shopRechtstexteArchiv', { $id: `${kunde}_rt`, shopId, customerId: kunde, hash: 'a'.repeat(64), texte: '{}', bestaetigtVon: 'K' }) const ordner = path.join(uploadDir, 'shop', siteKey) mkdirSync(ordner, { recursive: true }) writeFileSync(path.join(ordner, 'bild-l.webp'), 'x') @@ -640,8 +642,8 @@ test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe, assert.deepEqual(result.failed, []) assert.deepEqual(result.held, []) assert.equal(result.deleted.shops, 1) - assert.equal(result.deleted.shopDocs, 4) - for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopConfig', ['f_sh1_shop']]]) { + assert.equal(result.deleted.shopDocs, 5) + for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopRechtstexteArchiv', ['sh1_rt']], ['shopConfig', ['f_sh1_shop']]]) { for (const docId of ids) assert.ok(!aw.docs(col).has(docId), `${col} ${docId} weg`) } assert.ok(!existsSync(path.join(uploadDir, 'shop', SHOP_KEY)), 'Bilder weg') @@ -651,7 +653,7 @@ test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe, // Reihenfolge: Shopdaten vor den Vertragsereignissen und Abo-Dokumenten assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents')) assert.ok(reihenfolge.indexOf('contractEvents') < reihenfolge.indexOf('subscriptions')) - assert.match(formatReport(plan, result), /Webshop: 1 Shop\(s\) gelöscht \(4 Dokument\(e\)/) + assert.match(formatReport(plan, result), /Webshop: 1 Shop\(s\) gelöscht \(5 Dokument\(e\)/) }) test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente stehen (held) und der nächste Lauf setzt fort', async () => { diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js index 7f36423..dd82910 100644 --- a/test/shopAbschluss.test.js +++ b/test/shopAbschluss.test.js @@ -16,6 +16,7 @@ const aw = await startAppwriteMock({ shopConfig: schemaKeys('shopConfig'), shopProducts: schemaKeys('shopProducts'), shopOrders: schemaKeys('shopOrders'), + shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), termineOutbox: schemaKeys('termineOutbox'), }, datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, @@ -29,6 +30,7 @@ const { deleteDocument, updateDocument } = await import('../server/services/appw const { setMailTransportForTests } = await import('../server/services/mailer.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const K = await import('../server/services/shop/kontext.js') +const { archiviereRechtstexte } = await import('../server/services/shop/archiv.js') const B = await import('../server/services/shop/bestellungen.js') const J = await import('../server/services/shop/jobs.js') const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') @@ -87,7 +89,7 @@ let sink let log let ctx async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) { - for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) { + for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopRechtstexteArchiv', 'websiteProjects', 'termineOutbox', 'customers']) { for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) } aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' }) @@ -185,6 +187,23 @@ test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an assert.equal(order(b.orderId).status, 'bezahlt') }) +test('schliesseAb I-6: G09 enthält die Rechtstexte der Bestellung (Archiv zum Hash), auch wenn der Händler sie danach geändert und neu bestätigt hat', async () => { + await aufbau() + const b = await bestelle() + await archiviereRechtstexte({ shopId: FEATURE, customerId: KUNDE, hash: HASH, at: T0.toISOString(), von: 'k@example.invalid', texte: { ...TEXTE, agb: 'AGB ZUM ZEITPUNKT DER BESTELLUNG', versandUndZahlung: 'x', gewaehrleistung: 'GEWÄHRLEISTUNG DAMALS' } }) + // neue Fassung bestätigt, bevor bezahlt wird (SEPA: Tage später) + const neu = { ...TEXTE, agb: 'NEUE AGB NACH DER BESTELLUNG' } + aw.seed('shopConfig', shopDoc({ rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: { texte: neu, hash: 'e'.repeat(64), at: '2026-10-06T10:00:00.000Z', von: 'k@example.invalid' } }) })) + K.leereCache() + assert.equal(JSON.parse(order(b.orderId).bestellung).rechtstexteHash, HASH) + await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(120) }) + await warteAufVersand() + const g09 = sink.sent.find((m) => m.to[0].address === 'max@example.com') + assert.match(g09.text, /AGB ZUM ZEITPUNKT DER BESTELLUNG/) + assert.match(g09.text, /GEWÄHRLEISTUNG DAMALS/) + assert.doesNotMatch(g09.text, /NEUE AGB/) +}) + test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => { await aufbau() const b = await bestelle() diff --git a/test/shopMails.test.js b/test/shopMails.test.js index 6241de8..b3200c7 100644 --- a/test/shopMails.test.js +++ b/test/shopMails.test.js @@ -5,12 +5,13 @@ import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' // Webshop E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners // (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess. const aw = await startAppwriteMock({ - schema: { termineOutbox: schemaKeys('termineOutbox') }, - datetimes: TERMINE_DATETIMES, + schema: { termineOutbox: schemaKeys('termineOutbox'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv') }, + datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES }, defaultLimit: 25, }) after(() => aw.close()) @@ -23,6 +24,8 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js') const M = await import('../server/services/shop/mails.js') +const { archiviereRechtstexte, holeArchivierteRechtstexte, archivId } = await import('../server/services/shop/archiv.js') +const { normalisiereRechtstexte, MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR } = await import('../server/services/shop/rechtstexte.js') const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M after(() => setMailTransportForTests(null)) @@ -89,6 +92,7 @@ let sink let log beforeEach(async () => { for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id) + for (const id of aw.docs('shopRechtstexteArchiv').keys()) await deleteDocument(config.collections.shopRechtstexteArchiv, id) for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id) resetOutboxForTests() config.smtp.from = 'noreply@webklar.com' @@ -166,7 +170,6 @@ test('kaeuferWerte M-5: Telefonnummer des Anbieters steht in der Anbieterzeile, test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => { const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) assert.equal(erg.ok, true) - assert.equal(erg.gekuerzt, false) await warteAufVersand() const [doc] = eintraege() @@ -222,30 +225,78 @@ test('G09 Abholung: Abholort statt Lieferadresse', async () => { assert.deepEqual(warnLog(), []) }) -test('G09: zu lang für die Warteschlange ⇒ der AGB-Text wird durch einen Link auf die AGB ersetzt, Belehrung und Formular bleiben', async () => { - const lang = 'Dies ist ein langer Satz der Geschäftsbedingungen.\n'.repeat(560) // etwa 28 000 Zeichen - const t = texte({ agb: lang, widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(1000), widerrufsformular: 'Formular Zeile\n'.repeat(300) }) - const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) +/** Zeilen mit Satzzeichen und Sonderzeichen wie in echten Rechtstexten, genau n Zeichen lang. */ +const textMitLaenge = (n, zeile) => { + let t = '' + while (t.length < n) t += `${zeile}\n` + return t.slice(0, n) +} + +test('G09 I-7: Texte in Höchstlänge passen mit Volltext in die Warteschlange (html 100000, text 50000), nie ein Link statt der AGB', async () => { + const zeile = 'Ein Satz der Geschäftsbedingungen mit Komma, Klammern (wie hier) & einem „Zitat“ – und Zahlen 1.234,56 €.' + const t = texte({ + agb: textMitLaenge(MAX_AGB, zeile), + widerrufsbelehrung: textMitLaenge(MAX_WIDERRUFSBELEHRUNG, zeile), + widerrufsformular: textMitLaenge(MAX_WIDERRUFSFORMULAR, zeile), + }) + const geprueft = normalisiereRechtstexte({ ...t }) + assert.equal(geprueft.fehler, undefined, 'Höchstlänge wird vom Speichern akzeptiert') + const viele = Array.from({ length: 40 }, (_, i) => ({ name: `Artikel Nummer ${i + 1} mit langem Namen`, varianteText: 'Farbe: blau, Größe: M', menge: 3, summeCent: 12345, widerruf: i % 2 ? 'gesetzlich' : 'verderblich' })) + const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung({}, { positionen: viele }), {}, JETZT) assert.equal(erg.ok, true) - assert.equal(erg.gekuerzt, true) const doc = eintraege()[0] assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`) - assert.match(doc.text, new RegExp(`Die vollständigen AGB finden Sie unter https://project\\.webklar\\.com/shop/${SITE_KEY}\\?ansicht=agb`)) - assert.doesNotMatch(doc.text, /langer Satz der Geschäftsbedingungen/) - assert.match(doc.text, /Belehrung Satz\./) - assert.match(doc.text, /Formular Zeile/) - assert.match(doc.text, /GESETZLICHE GEWÄHRLEISTUNG/) + assert.equal(doc.text.split('Ein Satz der Geschäftsbedingungen').length - 1 >= 300, true, 'alle Texte stehen vollständig in der Mail') + assert.ok(doc.text.includes(t.agb.slice(-200).trim().split('\n').pop().slice(0, 30)), 'Ende der AGB') + assert.doesNotMatch(doc.text, /ansicht=agb|vollständigen AGB finden Sie/) }) -test('G09: auch gekürzt zu lang ⇒ { ok: false } ohne Wurf und ohne Eintrag', async () => { - const t = texte({ widerrufsbelehrung: 'x'.repeat(60000) }) +test('G09 I-7: zu lang für die Warteschlange ⇒ { ok: false } ohne Wurf, ohne Eintrag und ohne Link-Ersatz', async () => { + const t = texte({ agb: 'a&b\n'.repeat(4000), widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(500) }) const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }) assert.equal(eintraege().length, 0) assert.equal(sink.sent.length, 0) + assert.match(warnLog().join('\n'), /zu lang für die Warteschlange/) +}) + +test('G09 I-6: zeigt die archivierten Rechtstexte zum Hash der Bestellung, nicht die aktuell bestätigten', async () => { + const HASH = 'a'.repeat(64) + await archiviereRechtstexte({ + shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika', + texte: { agb: 'ALTE AGB', widerrufsbelehrung: 'ALTE BELEHRUNG', widerrufsformular: 'ALTES FORMULAR', impressumUrl: 'https://alt.example/impressum', datenschutzUrl: 'https://alt.example/datenschutz', versandUndZahlung: 'x', gewaehrleistung: 'ALTER GEWÄHRLEISTUNGSTEXT' }, + }) + const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: HASH }), {}, JETZT) + assert.equal(erg.ok, true) + const doc = eintraege()[0] + for (const teil of ['ALTE AGB', 'ALTE BELEHRUNG', 'ALTES FORMULAR', 'ALTER GEWÄHRLEISTUNGSTEXT']) assert.ok(doc.text.includes(teil), teil) + assert.doesNotMatch(doc.text, /AGB Zeile 1|Belehrung Zeile 1/) + assert.match(doc.html, /href="https:\/\/alt\.example\/impressum"/) + assert.deepEqual(warnLog(), []) +}) + +test('G09 I-6: fehlt die Fassung im Archiv, gilt mit Warnung die aktuell bestätigte; ohne Hash an der Bestellung ohne Warnung', async () => { + const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: 'b'.repeat(64) }), {}, JETZT) + assert.equal(erg.ok, true) + assert.match(eintraege()[0].text, /AGB Zeile 1/) + assert.match(warnLog().join('\n'), /fehlen im Archiv/) assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/) }) +test('Archiv I-6: Dokument-ID r + 35 Hex, ein zweites Anlegen derselben Fassung ist kein Fehler, fremder Shop findet sie nicht', async () => { + const HASH = 'c'.repeat(64) + assert.match(archivId('f_shop01', HASH), /^r[0-9a-f]{35}$/) + assert.notEqual(archivId('f_shop01', HASH), archivId('f_shop02', HASH)) + const p = { shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika', texte: { agb: 'A' } } + assert.deepEqual(await archiviereRechtstexte(p), { angelegt: true }) + assert.deepEqual(await archiviereRechtstexte(p), { angelegt: false }) + assert.deepEqual(await holeArchivierteRechtstexte('f_shop01', HASH), { agb: 'A' }) + assert.equal(await holeArchivierteRechtstexte('f_shop02', HASH), null) + assert.equal(await holeArchivierteRechtstexte('f_shop01', 'd'.repeat(64)), null) + const doc = [...aw.docs('shopRechtstexteArchiv').values()][0] + assert.deepEqual([doc.shopId, doc.customerId, doc.hash, doc.bestaetigtVon], ['f_shop01', 'c1', HASH, 'Erika']) +}) + test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => { assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true) assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true) diff --git a/test/shopRechtstexte.test.js b/test/shopRechtstexte.test.js index c9cb0a9..a1a39a2 100644 --- a/test/shopRechtstexte.test.js +++ b/test/shopRechtstexte.test.js @@ -14,6 +14,10 @@ import { BESTAETIGUNG_TEXT, pruefeBestaetigbar, MUSTER_KOPFZEILE, + MAX_AGB, + MAX_WIDERRUFSBELEHRUNG, + MAX_WIDERRUFSFORMULAR, + maskierteLaenge, } from '../server/services/shop/rechtstexte.js' import { STANDARD_EINSTELLUNGEN } from '../server/services/shop/einstellungen.js' @@ -244,13 +248,29 @@ test('normalisiereRechtstexte: gueltig, Entwurf mit leeren Texten, Bereinigung', assert.equal(vollstaendig(leer.rechtstexte), false) }) -test('normalisiereRechtstexte: Laengen', () => { - assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30000) }).rechtstexte) - assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(30001) }).fehler.agb) - assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20000) }).rechtstexte) - assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(20001) }).fehler.widerrufsbelehrung) - assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5000) }).rechtstexte) - assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(5001) }).fehler.widerrufsformular) +test('normalisiereRechtstexte: Laengen (I-7: so kurz, dass die Bestätigungsmail nie Links statt Texte braucht)', () => { + assert.deepEqual([MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR], [20000, 12000, 3000]) + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(20001) }).fehler.agb) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsbelehrung: 'a'.repeat(12001) }).fehler.widerrufsbelehrung) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(3000) }).rechtstexte) + assert.ok(normalisiereRechtstexte({ widerrufsformular: 'a'.repeat(3001) }).fehler.widerrufsformular) + // die Muster passen mit grossem Abstand hinein + const e = basis() + assert.ok(musterAgb(e).length < MAX_AGB / 4 && musterWiderrufsbelehrung(e, { widerrufUrl: WIDERRUF_URL }).length < MAX_WIDERRUFSBELEHRUNG / 2 && musterWiderrufsformular(e).length < MAX_WIDERRUFSFORMULAR / 2) +}) + +test('normalisiereRechtstexte I-7: viele Sonderzeichen und Umbrüche, die das HTML der Mail sprengen würden, werden beim Speichern abgelehnt', () => { + assert.equal(maskierteLaenge('a&b"\n\'x'), 1 + 5 + 1 + 4 + 1 + 4 + 6 + 4 + 5 + 1) + const r = normalisiereRechtstexte({ agb: '&'.repeat(10000), widerrufsbelehrung: '&'.repeat(2100) }) + assert.match(r.fehler.rechtstexte, /zu lang für die Bestätigungs-E-Mail/) + // gleich viele normale Zeichen sind in Ordnung + assert.ok(normalisiereRechtstexte({ agb: 'a'.repeat(12000), widerrufsbelehrung: 'a'.repeat(6000) }).rechtstexte) + // Höchstlänge mit Umbruch alle 100 Zeichen bleibt unter dem Mail-Budget + const zeile = `${'x'.repeat(99)}\n` + const voll = normalisiereRechtstexte({ agb: zeile.repeat(200), widerrufsbelehrung: zeile.repeat(120), widerrufsformular: zeile.repeat(30) }) + assert.equal(voll.fehler, undefined) }) test('normalisiereRechtstexte: URLs nur https ohne Zugangsdaten und Leerraum', () => { diff --git a/test/shopRoutes.test.js b/test/shopRoutes.test.js index 68e30e3..fb32c04 100644 --- a/test/shopRoutes.test.js +++ b/test/shopRoutes.test.js @@ -18,6 +18,7 @@ const aw = await startAppwriteMock({ portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), shopProducts: schemaKeys('shopProducts'), + shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), customerViewLog: schemaKeys('customerViewLog'), }, datetimes: SHOP_DATETIMES, @@ -32,6 +33,7 @@ const { kundenansichtGuard } = await import('../server/middleware/kundenansichtG const sf = await import('../server/services/siteFeatures.js') const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { shopPerSiteKey } = await import('../server/services/shop/kontext.js') +const { archivId } = await import('../server/services/shop/archiv.js') const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') @@ -117,7 +119,7 @@ beforeEach(async () => { config.shop.mode = 'pilot' config.shop.pilotEmails = [customers.c1.email] resetShopRoutesForTests() - for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) { + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopRechtstexteArchiv', 'customerViewLog']) { for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id) } await rm(process.env.SHOP_UPLOAD_DIR, { recursive: true, force: true }) @@ -481,7 +483,7 @@ test('Rechtstexte PUT: speichert den Entwurf, normalisiert, Fehler 400; Hash aen assert.ok(schlecht.body.fehler.impressumUrl && schlecht.body.fehler.datenschutzUrl) assert.equal((await schreiben('PUT', 'rechtstexte', { rechtstexte: 'text' })).status, 400) assert.equal((await schreiben('PUT', 'rechtstexte', {})).status, 400) - const zuLang = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'x'.repeat(30001) } }) + const zuLang = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...TEXTE, agb: 'x'.repeat(20001) } }) assert.equal(zuLang.status, 400) assert.equal((await lesen('rechtstexte')).body.entwurf.agb, 'Neue AGB\n\nZeile', 'Fehler aendert nichts') }) @@ -516,6 +518,16 @@ test('Rechtstexte bestaetigen: falscher oder alter Hash 409, unvollstaendig 400, assert.deepEqual(gespeichert.entwurf, texteFuer(feature)) assert.deepEqual([gespeichert.bestaetigt.texte, gespeichert.bestaetigt.hash], [texteFuer(feature), voll.body.hash]) assert.equal((await shopPerSiteKey(feature.siteKey)).shop.rechtstexte.bestaetigt.hash, voll.body.hash, 'Speicher wurde geleert') + // I-6: die bestaetigte Fassung liegt im Archiv (eigene ID, Texte samt Versand/Zahlung und Gewaehrleistung, Person und Zeit) + const archiv = [...aw.docs('shopRechtstexteArchiv').values()] + assert.equal(archiv.length, 1) + assert.equal(archiv[0].$id, archivId(feature.$id, voll.body.hash)) + assert.deepEqual([archiv[0].shopId, archiv[0].customerId, archiv[0].hash, archiv[0].bestaetigtVon], [feature.$id, 'c1', voll.body.hash, `${customers.c1.name} <${customers.c1.email}>`]) + assert.equal(new Date(archiv[0].bestaetigtAt).toISOString(), new Date(ok.body.bestaetigt.at).toISOString()) + const archTexte = JSON.parse(archiv[0].texte) + assert.deepEqual({ ...archTexte, versandUndZahlung: undefined, gewaehrleistung: undefined }, { ...texteFuer(feature), versandUndZahlung: undefined, gewaehrleistung: undefined }) + assert.equal(archTexte.versandUndZahlung, voll.body.versandUndZahlung) + assert.equal(archTexte.gewaehrleistung, voll.body.gewaehrleistung.text) // Entwurf aendern: die bestaetigte Fassung bleibt, ist aber nicht mehr aktuell; Bereitschaft verlangt neue Bestaetigung const geaendert = await schreiben('PUT', 'rechtstexte', { rechtstexte: { ...texteFuer(feature), agb: 'Geaenderte AGB' } }) diff --git a/test/shopSetup.test.js b/test/shopSetup.test.js index 7da087e..71607ca 100644 --- a/test/shopSetup.test.js +++ b/test/shopSetup.test.js @@ -15,7 +15,7 @@ const { main } = await import('../scripts/shop-setup.mjs') const { createDocument, updateDocument, getDocument } = await import('../server/services/appwriteAdmin.js') after(() => Promise.all([aw.close(), st.close()])) -const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe'] +const NAMES = ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv'] const keysOf = (name) => SHOP_COLLECTIONS[name].attributes.map((a) => a.key) /** @@ -127,6 +127,12 @@ test('Schema: Typen und Groessen', () => { [36, 36, 32, 200, 320, 2000, 36], ) for (const key of ['eingangAt', 'bestaetigungAt', 'bearbeitetAt']) assert.equal(widerrufe[key].type, 'datetime', key) + + // I-6: Archiv der bestaetigten Rechtstexte + assert.deepEqual(keysOf('shopRechtstexteArchiv'), ['shopId', 'customerId', 'hash', 'texte', 'bestaetigtAt', 'bestaetigtVon']) + const archiv = byKey('shopRechtstexteArchiv') + assert.deepEqual([archiv.shopId.size, archiv.customerId.size, archiv.hash.size, archiv.texte.size, archiv.bestaetigtVon.size], [36, 36, 64, 100000, 255]) + assert.equal(archiv.bestaetigtAt.type, 'datetime') }) test('Schema: jedes Attribut ist optional, hat eine gueltige Form und einen gueltigen Schluessel (kein Feld data)', () => { @@ -160,6 +166,7 @@ test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attribute ['idx_erstellt', 'shopId', 'erstelltAt'], ]) assert.deepEqual(indexes('shopWiderrufe'), [['idx_shop', 'shopId', 'eingangAt']]) + assert.deepEqual(indexes('shopRechtstexteArchiv'), [['idx_shop_hash', 'shopId', 'hash']]) for (const name of NAMES) { for (const index of SHOP_COLLECTIONS[name].indexes) { assert.equal(index.type, 'key') @@ -174,6 +181,7 @@ test('Schema: SHOP_DATETIMES nennt je Collection genau die datetime-Attribute', shopProducts: [], shopOrders: ['erstelltAt', 'ablaufAt', 'bezahltAt', 'anonymisiertAt'], shopWiderrufe: ['eingangAt', 'bestaetigungAt', 'bearbeitetAt'], + shopRechtstexteArchiv: ['bestaetigtAt'], }) }) @@ -215,7 +223,7 @@ test('Konfiguration: Collection-IDs der Shop-Collections', () => { assert.deepEqual(NAMES.map((name) => config.collections[name]), NAMES) }) -test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes an', async () => { +test('main legt auf leerer Datenbank 5 Collections, alle Attribute und Indizes an', async () => { const client = fakeClient() const result = await main({ client, log: quiet }) assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id }))) @@ -248,7 +256,7 @@ test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection assert.ok(!client.calls.collections.some((c) => c.id === 'shopWiderrufe')) assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.bearbeitetAt', 'shopWiderrufe.orderId']) assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith('shopWiderrufe.')), ['shopWiderrufe.idx_shop']) - assert.equal(client.calls.collections.length, 3) + assert.equal(client.calls.collections.length, 4) }) test('main --check legt nichts an und meldet Fehlendes', async () => {