Files
Webklar-Kundenbereich/server/services/shop/anfrage.js
T
ClaudeandClaude Opus 5.5 83605975b0 Webshop B1: Anfrage-Dienst, Kundenroute /api/shop und Team-Mail T21
Anfrage stellen, zurückziehen und Stand lesen (ensureShopConfig, normalisiereAnfrage,
stelleAnfrage, zieheZurueck, anfrageAnsicht), Router /api/shop mit Rate-Limit 20/h und
Sperre in der Kundenansicht, Vorlage T21 Neue Webshop-Anfrage mit Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:40:24 +00:00

233 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<object>} 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<object>} 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)
}