308 lines
15 KiB
JavaScript
308 lines
15 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
|
||
import { withLocks } from '../keyedLock.js'
|
||
import { sendAdminNotice, sendCustomerMail } 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.',
|
||
nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.',
|
||
entscheidung: 'Bitte wähle annehmen oder ablehnen.',
|
||
begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).',
|
||
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. */
|
||
export 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)
|
||
}
|
||
|
||
const BEGRUENDUNG_MAX = 1000
|
||
|
||
/** 'Name <email>' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */
|
||
const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin')
|
||
|
||
/**
|
||
* Das Team entscheidet eine offene Anfrage (B2): annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je
|
||
* Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Speichert Status, Zeit und Person
|
||
* (anfrageEntschiedenVon), bei annehmen eine leere Begruendung. Danach geht K24 an den Kunden (BCC Team); scheitert die Mail, bleibt
|
||
* die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`.
|
||
* @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes
|
||
*/
|
||
export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) {
|
||
if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung)
|
||
let grund = ''
|
||
if (entscheidung === 'ablehnen') {
|
||
grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : ''
|
||
if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung)
|
||
}
|
||
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
|
||
|
||
const { gespeichert, project } = await withLocks([`shop-anfrage:${projectId}`], async () => {
|
||
let besitz
|
||
try {
|
||
besitz = await assertOwnProject(customerId, projectId)
|
||
} catch (err) {
|
||
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
|
||
}
|
||
const feature = await getFeatureDoc(customerId, projectId, 'shop')
|
||
const shop = feature ? await leseShopConfig(feature.$id) : null
|
||
if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen')
|
||
const neu = await updateDocument(config.collections.shopConfig, shop.$id, {
|
||
anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt',
|
||
anfrageEntschiedenAt: now.toISOString(),
|
||
anfrageEntschiedenVon: personLabel(person).slice(0, 255),
|
||
anfrageBegruendung: grund,
|
||
version: (Number.isInteger(shop.version) ? shop.version : 0) + 1,
|
||
})
|
||
return { gespeichert: neu, project: besitz }
|
||
})
|
||
|
||
const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund }))
|
||
return { anfrage: anfrageAnsicht(gespeichert), mailFehler }
|
||
}
|
||
|
||
/** K24 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */
|
||
async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) {
|
||
try {
|
||
const kunde = await customerOf(customerId)
|
||
if (!kunde?.email) {
|
||
console.warn(`[shop] K24 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`)
|
||
return false
|
||
}
|
||
await sendCustomerMail(kunde.email, {
|
||
template: 'K24',
|
||
values: {
|
||
kunde_name: String(kunde.name || '').trim() || 'Kunde',
|
||
projekt: project?.projectName || projectId,
|
||
preis: config.features.shop.priceLabel,
|
||
ust_hinweis: config.ustHinweis,
|
||
begruendung,
|
||
portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(projectId)}`,
|
||
},
|
||
blocks: { angenommen, abgelehnt: !angenommen },
|
||
})
|
||
return true
|
||
} catch (err) {
|
||
console.warn(`[shop] K24 nicht gesendet: ${err?.message}`)
|
||
return false
|
||
}
|
||
}
|