import { config } from '../../config.js' import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendAdminNotice } from '../mailer.js' import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js' import { shopFuerKunde } from './schalter.js' /** * Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team * entscheidet (B2: annehmen oder ablehnen); erst eine angenommene Anfrage laesst die Bestellung der Funktion zu * (anfrageOffenePunkte, Haken bestellpruefung). Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des * portalFeatures-Dokuments des Projekts ist. anfrageStatus: '' (keine) | offen | angenommen | abgelehnt | zurueckgezogen. * Fehler an den Kunden tragen status (4xx) und einen deutschen Text (Du-Form), optional err.body.code. */ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigegeben.' export const WARENARTEN = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst', sonstiges: 'Sonstiges' } export const ARTIKELZAHLEN = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' } const BESCHREIBUNG_MIN = 10 const BESCHREIBUNG_MAX = 1500 const NACHRICHT_MAX = 1000 const MELDUNG = { projekt: 'Bitte wähle ein Projekt.', nichtVerfuegbar: 'Der Webshop ist noch nicht verfügbar.', vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.', laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.', nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.', ungueltig: 'Ungültige Eingabe.', } const stop = (status, text, code) => Object.assign(new Error(text), { status, ...(code ? { body: { code } } : {}) }) const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /** * Offene Punkte der Bestellpruefung (featureHaken('shop').bestellpruefung): [] wenn die Anfrage des Projekts vom Team angenommen * ist (anfrageStatus 'angenommen'), sonst ein Punkt. Ohne portalFeatures-Dokument, ohne shopConfig-Dokument oder bei jedem anderen * Status ist die Anfrage nicht freigegeben. Lesefehler werden weitergereicht (der Checkout bestellt dann nicht). */ export async function anfrageOffenePunkte({ customerId, projectId }) { const doc = await getFeatureDoc(customerId, projectId, 'shop') if (!doc) return [ANFRAGE_OFFEN_TEXT] const shop = await leseShopConfig(doc.$id) return shop?.anfrageStatus === 'angenommen' ? [] : [ANFRAGE_OFFEN_TEXT] } /** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */ async function leseShopConfig(id) { try { return await getDocument(config.collections.shopConfig, id) } catch (err) { if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null throw err } } /** * shopConfig-Dokument zum portalFeatures-Dokument holen oder anlegen (ID gleich, siteKey kopiert, Anfrage leer, Version 0). * Zwei gleichzeitige Anlagen: die zweite bekommt 409 und liest das Dokument der ersten. */ export async function ensureShopConfig(featureDoc) { const vorhanden = await leseShopConfig(featureDoc.$id) if (vorhanden) return vorhanden try { return await createDocument( config.collections.shopConfig, { customerId: featureDoc.customerId, projectId: featureDoc.projectId, siteKey: featureDoc.siteKey, anfrageStatus: '', version: 0, }, featureDoc.$id ) } catch (err) { if (err?.status === 409) { const nochmal = await leseShopConfig(featureDoc.$id) if (nochmal) return nochmal } throw err } } /** * Eingabe der Anfrage pruefen => { anfrage } (nur bekannte Felder, bereinigt) oder { fehler: { feld: text } }. * warenart, beschreibung (10-1500 Zeichen), artikelzahl, versand/abholung (mindestens eins), unternehmer und keineAusgeschlossenen * muessen true sein, nachricht (optional, bis 1000 Zeichen). */ export function normalisiereAnfrage(eingabe) { const fehler = {} const e = istObjekt(eingabe) ? eingabe : {} const text = (wert) => (typeof wert === 'string' ? wert.toWellFormed().trim() : '') const warenart = typeof e.warenart === 'string' && Object.hasOwn(WARENARTEN, e.warenart) ? e.warenart : '' if (!warenart) fehler.warenart = 'Bitte wähle, was du verkaufen möchtest.' const beschreibung = text(e.beschreibung) if (beschreibung.length < BESCHREIBUNG_MIN) fehler.beschreibung = `Bitte beschreibe kurz, was du verkaufen möchtest (mindestens ${BESCHREIBUNG_MIN} Zeichen).` else if (beschreibung.length > BESCHREIBUNG_MAX) fehler.beschreibung = `Die Beschreibung darf höchstens ${BESCHREIBUNG_MAX} Zeichen lang sein.` const artikelzahl = typeof e.artikelzahl === 'string' && Object.hasOwn(ARTIKELZAHLEN, e.artikelzahl) ? e.artikelzahl : '' if (!artikelzahl) fehler.artikelzahl = 'Bitte wähle, wie viele Artikel du etwa anbieten möchtest.' if (e.versand !== undefined && typeof e.versand !== 'boolean') fehler.versand = MELDUNG.ungueltig if (e.abholung !== undefined && typeof e.abholung !== 'boolean') fehler.abholung = MELDUNG.ungueltig const versand = e.versand === true const abholung = e.abholung === true if (!fehler.versand && !fehler.abholung && !versand && !abholung) fehler.versand = 'Bitte wähle Versand, Abholung oder beides.' if (e.unternehmer !== true) fehler.unternehmer = 'Bitte bestätige, dass du als Unternehmer verkaufst.' if (e.keineAusgeschlossenen !== true) fehler.keineAusgeschlossenen = 'Bitte bestätige, dass du keine ausgeschlossenen Waren verkaufst.' let nachricht = '' if (e.nachricht !== undefined && e.nachricht !== null && e.nachricht !== '') { if (typeof e.nachricht !== 'string') fehler.nachricht = MELDUNG.ungueltig else { nachricht = text(e.nachricht) if (nachricht.length > NACHRICHT_MAX) fehler.nachricht = `Die Nachricht darf höchstens ${NACHRICHT_MAX} Zeichen lang sein.` } } if (Object.keys(fehler).length) return { fehler } return { anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, unternehmer: true, keineAusgeschlossenen: true, nachricht } } } /** Anfrage fuer den Browser: { status, anfrage, at, entschiedenAt, begruendung } oder null, solange es keine gab. Unlesbares JSON => anfrage null. */ export function anfrageAnsicht(shopDoc) { const status = shopDoc?.anfrageStatus || '' if (!status) return null let anfrage = null try { const roh = typeof shopDoc.anfrage === 'string' && shopDoc.anfrage ? JSON.parse(shopDoc.anfrage) : null anfrage = istObjekt(roh) ? roh : null } catch { anfrage = null } return { status, anfrage, at: shopDoc.anfrageAt || null, entschiedenAt: shopDoc.anfrageEntschiedenAt || null, begruendung: shopDoc.anfrageBegruendung || '', } } /** Projekt des Kunden (Appwrite-Fehler 400 bei unbrauchbarer ID zaehlt als fremdes Projekt) und Schalter des Kunden. */ async function pruefeZugang({ customerId, projectId, person, neu }) { if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt) let project try { project = await assertOwnProject(customerId, projectId, { forNewOrder: neu }) } catch (err) { throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err } if (!shopFuerKunde(person?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') return project } /** * Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre * je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen` * und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt). * @returns {Promise} anfrageAnsicht des neuen Standes */ export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) { const project = await pruefeZugang({ customerId, projectId, person, neu: true }) const { anfrage, fehler } = normalisiereAnfrage(eingabe) if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler }) const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { const feature = await ensureFeatureDoc(customerId, projectId, 'shop') if (feature.state !== 'off') throw stop(409, MELDUNG.vorhanden, 'vorhanden') const shop = await ensureShopConfig(feature) if (!['', 'abgelehnt', 'zurueckgezogen'].includes(shop.anfrageStatus || '')) throw stop(409, MELDUNG.laeuft, 'anfrage_laeuft') return updateDocument(config.collections.shopConfig, shop.$id, { anfrage: JSON.stringify(anfrage), anfrageStatus: 'offen', anfrageAt: now.toISOString(), anfrageEntschiedenAt: null, anfrageEntschiedenVon: '', anfrageBegruendung: '', version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, }) }) await meldeTeam({ customerId, projectId, project, anfrage, person }) return anfrageAnsicht(gespeichert) } /** T21 "Neue Webshop-Anfrage" an das Team; sendAdminNotice wirft nie, ein Fehlschlag steht nur im Log (die Anfrage ist gespeichert). */ async function meldeTeam({ customerId, projectId, project, anfrage, person }) { try { let kundeName = String(person?.name || '').trim() if (!kundeName) kundeName = String((await customerOf(customerId))?.name || '').trim() const lieferung = [anfrage.versand ? 'Versand' : '', anfrage.abholung ? 'Abholung' : ''].filter(Boolean).join(' und ') const result = await sendAdminNotice({ template: 'T21', values: { kunde_name: kundeName || 'Kunde', email: person?.email || '', projekt: project?.projectName || projectId, warenart: WARENARTEN[anfrage.warenart], beschreibung: anfrage.beschreibung, artikelzahl: ARTIKELZAHLEN[anfrage.artikelzahl], lieferung, nachricht: anfrage.nachricht || '–', woms_url: `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId)}?tab=funktionen`, }, }) if (!result?.sent) console.warn(`[shop] Team-Mail T21 nicht gesendet: ${result?.reason || 'unbekannt'}`) } catch (err) { console.warn(`[shop] Team-Mail T21 nicht gesendet: ${err?.message}`) } } /** Offene Anfrage zurueckziehen (Status `zurueckgezogen`); aus jedem anderen Zustand 409. @returns {Promise} anfrageAnsicht */ export async function zieheZurueck({ customerId, projectId, person = {} }) { await pruefeZugang({ customerId, projectId, person, neu: false }) const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => { const feature = await getFeatureDoc(customerId, projectId, 'shop') const shop = feature ? await leseShopConfig(feature.$id) : null if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtOffen, 'nicht_offen') return updateDocument(config.collections.shopConfig, shop.$id, { anfrageStatus: 'zurueckgezogen', version: (Number.isInteger(shop.version) ? shop.version : 0) + 1, }) }) return anfrageAnsicht(gespeichert) }