Shop Review B I-6, I-7: Archiv der bestaetigten Rechtstexte, G09 ohne Link-Ersatz
I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell bestaetigte), loescheShopDaten loescht das Archiv mit. I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f4f360a21
commit
08d837d690
17 files changed
+288
-65
No files matched your search
+2
-1
@@ -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(/\/$/, ''),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<object|null>}
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> }} 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 (;;) {
|
||||
|
||||
@@ -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<string, string>}
|
||||
*/
|
||||
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.' }
|
||||
|
||||
@@ -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 <br>). */
|
||||
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 <br>)
|
||||
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: {
|
||||
|
||||
@@ -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.<Name>; 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. */
|
||||
|
||||
Reference in new issue
Block a user