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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:40:24 +00:00
1 parent 0063c04d8a
commit 83605975b0
7 files changed
+801 -17

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
import funktionenRoutes from './routes/funktionen.js'
import termineRoutes from './routes/termine.js'
import shopRoutes from './routes/shop.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
@@ -79,6 +80,7 @@ app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
app.use('/api/funktionen', funktionenRoutes)
app.use('/api/termine', termineRoutes)
app.use('/api/shop', shopRoutes)
app.use('/api/team', teamRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminPreviewRoutes)
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T21 · Neue Webshop-Anfrage
Betreff: [Webshop] Neue Anfrage: {{kunde_name}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{warenart}}, {{beschreibung}}, {{artikelzahl}}, {{lieferung}}, {{nachricht}}, {{woms_url}}
Die Vorlage hat keine optionalen Blöcke. Ein Kunde hat im Portal eine Webshop-Anfrage gestellt („auf Anfrage“); das Team entscheidet im Ticketsystem (Kunde, Reiter Funktionen): annehmen oder ablehnen.
warenart, artikelzahl und lieferung sind die lesbaren Texte der Auswahl; nachricht steht „–“, wenn der Kunde keine geschrieben hat. woms_url führt zum Kunden im Ticketsystem (Reiter Funktionen).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Webshop] Neue Anfrage: {{kunde_name}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}} hat eine Webshop-Anfrage gestellt (Projekt {{projekt}}).&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Webshop</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Webshop-Anfrage</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Warenart</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{warenart}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Artikelzahl</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{artikelzahl}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Lieferung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{lieferung}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Beschreibung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{beschreibung}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Nachricht</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{nachricht}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{woms_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem entscheiden &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
import { Router } from 'express'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { config } from '../config.js'
import { isKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { anfrageAnsicht, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
import { shopFuerKunde } from '../services/shop/schalter.js'
/**
* Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Dieser Stand (Task B1) enthaelt
* die Anfrage: GET /status, POST /anfrage, POST /anfrage/zurueckziehen. Weitere Routen kommen mit C7, D2 und E5.
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Schreib-Routen sind in der Kundenansicht gesperrt
* (kundenansichtGuard: nicht auf der Erlaubnisliste; hier zusaetzlich 403) und zaehlen gegen 20 Aufrufe je Kunde und Stunde.
* Fehler: { error: <Text>, fehler?: { <feld>: Text }, code? }; Texte von Appwrite oder unbekannten Fehlern verlassen den Server nie.
*/
const router = Router()
const MAX_WRITES_PER_HOUR = 20
const MELDUNG = {
projekt: 'Bitte wähle ein Projekt.',
fremd: 'Kein Zugriff auf dieses Projekt.',
kundenansicht: 'In der Kundenansicht kann das nicht geändert werden.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
}
/** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */
function fail(res, err) {
const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type
if (!own) {
console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim())
return res.status(500).json({ error: 'Interner Serverfehler' })
}
const body = { ...err.body, error: err.message }
if (err.fehler && typeof err.fehler === 'object') body.fehler = err.fehler
return res.status(err.status).json(body)
}
const route = (fn) => async (req, res, next) => {
try {
await fn(req, res, next)
} catch (err) {
fail(res, err)
}
}
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetShopRoutesForTests() {
limiter.reset()
}
const nurKunde = (req, res, next) => {
if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.kundenansicht })
return next()
}
const person = (req) => ({ name: req.session?.name || '', email: req.session?.email || '' })
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
/**
* Stand des Webshops fuer ein Projekt: { verfuegbar, state, anfrage }. verfuegbar = Schalter fuer diesen Kunden;
* state = Zustand der Funktion (off, wenn es noch kein Dokument gibt); anfrage = anfrageAnsicht oder null. Legt nichts an.
*/
router.get('/status', requireSession, route(async (req, res) => {
const projectId = projectIdOf(req)
if (!projectId) throw Object.assign(new Error(MELDUNG.projekt), { status: 400 })
const customerId = getSessionCustomerId(req)
try {
await assertOwnProject(customerId, projectId)
} catch (err) {
throw err?.status === 403 || err?.status === 400 ? Object.assign(new Error(MELDUNG.fremd), { status: 403 }) : err
}
const verfuegbar = shopFuerKunde(req.session?.email)
const feature = await getFeatureDoc(customerId, projectId, 'shop')
let shop = null
if (feature) {
try {
shop = await getDocument(config.collections.shopConfig, feature.$id)
} catch (err) {
if (!(err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found')) throw err
}
}
res.json({ verfuegbar, state: feature?.state || 'off', anfrage: shop ? anfrageAnsicht(shop) : null })
}))
router.post('/anfrage', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const anfrage = await stelleAnfrage({
customerId: getSessionCustomerId(req),
projectId: projectIdOf(req),
eingabe: req.body?.anfrage,
person: person(req),
})
res.json({ anfrage })
}))
router.post('/anfrage/zurueckziehen', requireSession, nurKunde, limitWrites, route(async (req, res) => {
const anfrage = await zieheZurueck({ customerId: getSessionCustomerId(req), projectId: projectIdOf(req), person: person(req) })
res.json({ anfrage })
}))
export default router
+215 -12
View File
@@ -1,15 +1,39 @@
import { config } from '../../config.js'
import { getDocument } from '../appwriteAdmin.js'
import { getFeatureDoc } from '../siteFeatures.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"). Dieser Stand ist der Rumpf aus
* Task A3: nur die Bestellpruefung; Block B (B1, B2) baut Anfrage stellen, zurueckziehen und die Team-Entscheidung aus.
* Die Anfrage steht im Dokument shopConfig, dessen ID gleich der ID des portalFeatures-Dokuments des Projekts ist.
* 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
@@ -18,12 +42,191 @@ export const ANFRAGE_OFFEN_TEXT = 'Deine Webshop-Anfrage ist noch nicht freigege
export async function anfrageOffenePunkte({ customerId, projectId }) {
const doc = await getFeatureDoc(customerId, projectId, 'shop')
if (!doc) return [ANFRAGE_OFFEN_TEXT]
let shop
try {
shop = await getDocument(config.collections.shopConfig, doc.$id)
} catch (err) {
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return [ANFRAGE_OFFEN_TEXT]
throw err
}
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)
}