Ueber die gemeinsame Empfaenger-Kappe (darfEmpfaenger) geht an dieselbe Adresse hoechstens dreimal je Stunde ein G12. Darueber hinaus wird der Widerruf trotzdem gespeichert und mit eingang beantwortet, es wird aber keine weitere Mail eingereiht (bestaetigungAt bleibt leer). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
11 KiB
JavaScript
223 lines
11 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { createDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { darfEmpfaenger, darfWiderrufsversuch, zaehle } from '../termine/limits.js'
|
|
import { pruefeNow } from '../termine/util.js'
|
|
import { datumKurz, uhrzeit } from '../termine/zeit.js'
|
|
import { bestellDaten } from './bestellungen.js'
|
|
import { anHaendler, anKaeufer, haendlerK26 } from './mails.js'
|
|
|
|
/**
|
|
* Widerrufe von Kaeufern (§ 356a BGB; Spezifikation 2026-10-05-webshop-design.md Abschnitt 5.4; Plan Task E5).
|
|
*
|
|
* Der Kaeufer erklaert den Widerruf ueber die oeffentliche Widerrufsseite des Shops (Name, E-Mail als Kontakt, Bestellnummer als
|
|
* freiwillige Vertragsbezeichnung; ohne Nummer ist die Nachricht mit Bestelldatum und Artikel Pflicht). erfasseWiderruf speichert die Erklaerung (shopWiderrufe), haengt sie an die passende
|
|
* Bestellung (gleiche Nummer und gleiche E-Mail) und bestaetigt den Eingang sofort mit G12 (Datum und Uhrzeit, Berliner Zeit, auf
|
|
* dem dauerhaften Datentraeger E-Mail). K26 informiert den Haendler. Mailfehler lassen die Anfrage nie scheitern: dann bleibt
|
|
* bestaetigungAt leer. Die Bestellnummer muss nicht existieren (der Widerruf ist trotzdem wirksam erklaert).
|
|
* Gilt fuer Shops im Zustand active und disabled, auch wenn der Shop gerade geschlossen ist; die Pruefung der Funktion
|
|
* (off/unbekannt) liegt in der Route (Task F3).
|
|
* Fehler: Error mit status und Kaeufertext (Sie-Form) in message; 429 bei Grenzen, 404 bei fremden Widerrufen.
|
|
*/
|
|
|
|
const STUNDE_MS = 60 * 60 * 1000
|
|
const MAX_JE_SHOP_UND_STUNDE = 20
|
|
const SEITE = 50
|
|
const MAX_JSON = 60000
|
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
|
const NUMMER_RE = /^[A-Z0-9-]{1,32}$/
|
|
/** Eine einzelne Adresse: kein Leerraum, keine Listen-/Namensformen. */
|
|
const EMAIL_RE = /^[^\s@,;:<>()[\]"\\]+@[^\s@,;:<>()[\]"\\]+\.[^\s@,;:<>()[\]"\\.]{2,}$/
|
|
|
|
const ZU_VIELE = 'Zu viele Versuche. Bitte versuchen Sie es später noch einmal.'
|
|
const NICHT_GEFUNDEN = 'Der Widerruf wurde nicht gefunden.'
|
|
|
|
const col = () => config.collections.shopWiderrufe
|
|
|
|
function fehler(status, text) {
|
|
return Object.assign(new Error(text), { status })
|
|
}
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
const STEUER_ALLE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g
|
|
// eslint-disable-next-line no-control-regex
|
|
const STEUER_OHNE_UMBRUCH = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g
|
|
|
|
/** Einzeiliger Text: Steuerzeichen raus, Leerraum zusammengefasst. */
|
|
const einzeilig = (wert) => (typeof wert === 'string' ? wert.replace(STEUER_ALLE, ' ').replace(/\s+/g, ' ').trim() : '')
|
|
/** Mehrzeiliger Text: Steuerzeichen (ausser Zeilenumbruch und Tab) raus. */
|
|
const mehrzeilig = (wert) => (typeof wert === 'string' ? wert.replace(/\r\n?/g, '\n').replace(STEUER_OHNE_UMBRUCH, '').trim() : '')
|
|
|
|
// ---------------------------------------------------------------- Pruefung
|
|
|
|
/**
|
|
* Prueft und bereinigt die Eingabe des Widerrufsformulars.
|
|
* @param {{ name?: string, email?: string, bestellnummer?: string, nachricht?: string }} eingabe
|
|
* @returns {{ widerruf: { name: string, email: string, bestellnummer: string, nachricht: string } } | { fehler: Record<string, string> }}
|
|
*/
|
|
export function normalisiereWiderruf(eingabe) {
|
|
const roh = eingabe !== null && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
|
const f = {}
|
|
|
|
const name = einzeilig(roh.name)
|
|
if (name.length < 2) f.name = 'Bitte geben Sie Ihren Namen an.'
|
|
else if (name.length > 200) f.name = 'Der Name darf höchstens 200 Zeichen lang sein.'
|
|
|
|
// Steuerzeichen werden zu Leerzeichen: Leerraum im Inneren macht die Adresse ungueltig (keine zweite Adresse einschmuggeln)
|
|
const email = typeof roh.email === 'string' ? roh.email.replace(STEUER_ALLE, ' ').trim().toLowerCase() : ''
|
|
if (email === '') f.email = 'Bitte geben Sie Ihre E-Mail-Adresse an.'
|
|
else if (email.length > 254 || !EMAIL_RE.test(email)) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.'
|
|
|
|
const nummer = einzeilig(roh.bestellnummer).toUpperCase()
|
|
// Die Bestellnummer ist freiwillig (§ 356a BGB: der Kaeufer hat sie nicht immer zur Hand); ohne sie muss die Erklaerung den Vertrag benennen
|
|
if (nummer !== '' && !NUMMER_RE.test(nummer)) f.bestellnummer = 'Die Bestellnummer darf nur aus Buchstaben, Ziffern und Bindestrichen bestehen und höchstens 32 Zeichen lang sein.'
|
|
|
|
const nachricht = mehrzeilig(roh.nachricht)
|
|
if (nachricht.length > 2000) f.nachricht = 'Die Nachricht darf höchstens 2000 Zeichen lang sein.'
|
|
else if (nummer === '' && nachricht === '') f.nachricht = 'Bitte nennen Sie Bestelldatum und Artikel, damit wir Ihren Vertrag zuordnen können.'
|
|
|
|
if (Object.keys(f).length > 0) return { fehler: f }
|
|
return { widerruf: { name, email, bestellnummer: nummer, nachricht } }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Erfassen
|
|
|
|
/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */
|
|
export const eingangText = (zeit) => `${datumKurz(zeit)}, ${uhrzeit(zeit)} Uhr`
|
|
|
|
/** Die Bestellung zu Nummer und E-Mail im selben Shop (oder null). Anonymisierte Bestellungen (leere E-Mail) passen nie. */
|
|
async function passendeBestellung(shopId, bestellnummer, email) {
|
|
const docs = await listDocuments(config.collections.shopOrders, [
|
|
Query.equal('shopId', shopId),
|
|
Query.equal('bestellnummer', bestellnummer),
|
|
Query.limit(10),
|
|
])
|
|
return docs.find((d) => typeof d.kaeuferEmail === 'string' && d.kaeuferEmail.trim().toLowerCase() === email) ?? null
|
|
}
|
|
|
|
/** Verlauf der Bestellung um den Widerruf ergaenzen (Bestleistung: Fehler werden verschluckt). */
|
|
async function verlaufErgaenzen(orderId, now) {
|
|
try {
|
|
await withLocks([`shop-order:${orderId}`], async () => {
|
|
const frisch = await getDocument(config.collections.shopOrders, orderId)
|
|
const daten = bestellDaten(frisch)
|
|
const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: now.toISOString(), was: 'widerruf' }]
|
|
const json = JSON.stringify({ ...daten, verlauf })
|
|
if (json.length > MAX_JSON) return
|
|
await updateDocument(config.collections.shopOrders, orderId, { bestellung: json })
|
|
})
|
|
} catch (err) {
|
|
console.warn(`[shop-widerrufe] Verlauf nicht ergänzt: ${err?.name} ${String(err?.status ?? '').slice(0, 10)}`.trim())
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nimmt einen Widerruf entgegen.
|
|
* @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js); doc.state active oder disabled
|
|
* @param {object} eingabe { name, email, bestellnummer, nachricht }
|
|
* @param {{ now?: Date, ip?: string }} [optionen]
|
|
* @returns {Promise<{ ok: true, eingang: string, id: string }>}
|
|
* @throws {Error} status 429 (Grenzen) oder 400 mit fehler: { feld: Text }
|
|
*/
|
|
export async function erfasseWiderruf(ctx, eingabe, { now = new Date(), ip } = {}) {
|
|
pruefeNow(now, 'erfasseWiderruf')
|
|
const shopId = ctx?.shop?.id
|
|
if (typeof shopId !== 'string' || shopId === '') throw new TypeError('erfasseWiderruf: ctx.shop.id fehlt')
|
|
|
|
if (!darfWiderrufsversuch(ip, now) || !zaehle(`shop-widerruf:${shopId}`, MAX_JE_SHOP_UND_STUNDE, STUNDE_MS, now)) {
|
|
throw fehler(429, ZU_VIELE)
|
|
}
|
|
|
|
const geprueft = normalisiereWiderruf(eingabe)
|
|
if (geprueft.fehler) throw Object.assign(fehler(400, Object.values(geprueft.fehler)[0]), { fehler: geprueft.fehler })
|
|
const { name, email, bestellnummer, nachricht } = geprueft.widerruf
|
|
|
|
const order = bestellnummer === '' ? null : await passendeBestellung(shopId, bestellnummer, email)
|
|
|
|
const doc = await createDocument(col(), {
|
|
shopId,
|
|
customerId: String(ctx.shop.customerId ?? '').slice(0, 36),
|
|
bestellnummer,
|
|
name,
|
|
email,
|
|
nachricht,
|
|
eingangAt: now.toISOString(),
|
|
orderId: order?.$id ?? '',
|
|
})
|
|
if (order) await verlaufErgaenzen(order.$id, now)
|
|
|
|
// G12: Eingangsbestaetigung auf dauerhaftem Datentraeger, sofort; bestaetigungAt nur, wenn die Warteschlange sie angenommen hat
|
|
// Pro Empfaengeradresse hoechstens 3 je Stunde (gemeinsame Kappe der Funktionen): sonst liesse sich G12 als Mail-Relay an fremde
|
|
// Adressen missbrauchen. Der Widerruf ist gespeichert und beantwortet, die Bestaetigung entfaellt dann (bestaetigungAt bleibt leer).
|
|
try {
|
|
const g12 = !darfEmpfaenger(email.trim().toLowerCase(), now) ? null : await anKaeufer('G12', ctx, order, { an: email, bookingId: doc.$id, name, bestellnummer, eingang: now, nachricht }, now)
|
|
if (g12?.ok) await updateDocument(col(), doc.$id, { bestaetigungAt: now.toISOString() })
|
|
} catch (err) {
|
|
console.warn(`[shop-widerrufe] G12 nicht bestätigt: ${err?.name}`)
|
|
}
|
|
|
|
try {
|
|
const { values, blocks } = haendlerK26(ctx, { name, email, bestellnummer, nachricht, eingang: now }, now)
|
|
await anHaendler('K26', ctx, values, blocks)
|
|
} catch (err) {
|
|
console.warn(`[shop-widerrufe] K26 nicht gesendet: ${err?.name}`)
|
|
}
|
|
|
|
return { ok: true, eingang: eingangText(now), id: doc.$id }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Verwalten
|
|
|
|
const ansicht = (d) => ({
|
|
id: d.$id,
|
|
bestellnummer: d.bestellnummer ?? '',
|
|
name: d.name ?? '',
|
|
email: d.email ?? '',
|
|
nachricht: d.nachricht ?? '',
|
|
eingangAt: d.eingangAt ?? '',
|
|
bestaetigungAt: d.bestaetigungAt || null,
|
|
bearbeitetAt: d.bearbeitetAt || null,
|
|
orderId: d.orderId || null,
|
|
})
|
|
|
|
/**
|
|
* Widerrufe des Shops, neueste zuerst, 50 je Seite.
|
|
* @param {string} shopId
|
|
* @param {{ seite?: number }} [optionen] seite ab 1
|
|
* @returns {Promise<{ widerrufe: object[], seite: number, mehr: boolean }>}
|
|
*/
|
|
export async function listeWiderrufe(shopId, { seite = 1 } = {}) {
|
|
const nr = Number.isInteger(seite) && seite >= 1 ? seite : 1
|
|
const filter = [Query.equal('shopId', shopId), Query.orderDesc('eingangAt')]
|
|
// Der Aufruf kennt keinen Offset: Seite fuer Seite per Cursor bis zur gewuenschten
|
|
let cursor = null
|
|
let docs = []
|
|
for (let i = 1; i <= nr; i += 1) {
|
|
docs = await listDocuments(col(), [...filter, ...(cursor ? [Query.cursorAfter(cursor)] : []), Query.limit(SEITE + 1)])
|
|
if (i < nr) {
|
|
if (docs.length <= SEITE) return { widerrufe: [], seite: nr, mehr: false }
|
|
cursor = docs[SEITE - 1].$id
|
|
}
|
|
}
|
|
return { widerrufe: docs.slice(0, SEITE).map(ansicht), seite: nr, mehr: docs.length > SEITE }
|
|
}
|
|
|
|
/**
|
|
* Markiert einen Widerruf als bearbeitet (nur im eigenen Shop, sonst 404). Ein zweiter Aufruf aendert den Zeitpunkt nicht.
|
|
* @returns {Promise<object>} der Widerruf
|
|
*/
|
|
export async function markiereBearbeitet(shopId, id, { now = new Date() } = {}) {
|
|
pruefeNow(now, 'markiereBearbeitet')
|
|
if (typeof id !== 'string' || !ID_RE.test(id)) throw fehler(404, NICHT_GEFUNDEN)
|
|
let doc
|
|
try {
|
|
doc = await getDocument(col(), id)
|
|
} catch (err) {
|
|
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') throw fehler(404, NICHT_GEFUNDEN)
|
|
throw err
|
|
}
|
|
if (doc.shopId !== shopId) throw fehler(404, NICHT_GEFUNDEN)
|
|
if (doc.bearbeitetAt) return ansicht(doc)
|
|
return ansicht(await updateDocument(col(), id, { bearbeitetAt: now.toISOString() }))
|
|
}
|