220 lines
10 KiB
JavaScript
220 lines
10 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { createDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { 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, Bestellnummer als Vertragsbezeichnung,
|
|
* E-Mail als Kontakt, optionale Nachricht). 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()
|
|
if (nummer === '') f.bestellnummer = 'Bitte geben Sie die Bestellnummer an.'
|
|
else if (!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.'
|
|
|
|
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 = 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
|
|
try {
|
|
const g12 = 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() }))
|
|
}
|