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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:58:31 +00:00
1 parent 2f4f360a21
commit 08d837d690
17 files changed
+288 -65

No files matched your search

+2 -1
View File
@@ -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(/\/$/, ''),
+10
View File
@@ -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, {
+2 -2
View File
@@ -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 () => {
+60
View File
@@ -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
}
}
+5 -4
View File
@@ -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 (;;) {
+27 -18
View File
@@ -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.' }
+25 -3
View File
@@ -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: {
+9 -1
View File
@@ -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. */