Webshop B2: Team-Entscheidung über die Anfrage, Übersicht /api/team/shop und Kundenmail K24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:43:38 +00:00
1 parent 608ff61d3d
commit 0fb84021f8
6 files changed
+512 -4

No files matched your search

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K24 · Webshop-Anfrage entschieden (Annahme oder Ablehnung)
Betreff: Deine Webshop-Anfrage für {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{ust_hinweis}}, {{begruendung}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angenommen, abgelehnt
Genau einer der Blöcke ist an. angenommen = Anfrage angenommen, der Kunde kann den Webshop jetzt bestellen; abgelehnt = Anfrage abgelehnt, mit Begründung des Teams.
preis = Preis der Einrichtung, z. B. „299 €“; ust_hinweis = Umsatzsteuerhinweis zum Preis; portal_url = Webshop-Seite des Projekts im Kundenportal.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<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>Deine Webshop-Anfrage für {{projekt}}</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;">Deine Webshop-Anfrage wurde bearbeitet.&#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;">Webshop</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:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">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;"><!-- WENN angenommen -->Deine Webshop-Anfrage wurde angenommen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zu Deiner Webshop-Anfrage<!-- ENDE abgelehnt --></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN angenommen -->wir haben Deine Webshop-Anfrage für {{projekt}} angenommen. Du kannst den Webshop jetzt im Kundenportal bestellen; danach richten wir ihn ein.<!-- ENDE angenommen --><!-- WENN abgelehnt -->leider können wir Deine Webshop-Anfrage für {{projekt}} nicht annehmen. Den Grund findest Du unten. Wenn Du Fragen hast, antworte einfach auf diese E-Mail.<!-- ENDE abgelehnt --></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;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{projekt}}</strong></td></tr><!-- WENN angenommen --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Preis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">einmalig {{preis}} ({{ust_hinweis}})</td></tr><!-- ENDE angenommen --><!-- WENN abgelehnt --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Begründung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">{{begruendung}}</td></tr><!-- ENDE abgelehnt --></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="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;"><!-- WENN angenommen -->Webshop bestellen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zum Kundenportal<!-- ENDE abgelehnt --> &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+3
View File
@@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js'
import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
import shopTeamRoutes from './shop.js'
import termineTeamRoutes from './termine.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
@@ -25,5 +26,7 @@ router.use('/storage-tasks', storageTasksAdminRouter)
router.use('/funktionen', funktionenAdminRouter)
// Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob)
router.use('/termine', termineTeamRoutes)
// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage
router.use('/shop', shopTeamRoutes)
export default router
+92
View File
@@ -0,0 +1,92 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js'
import { anfrageAnsicht, entscheideAnfrage, leseShopConfig } from '../../services/shop/anfrage.js'
import { listCustomerFeatureDocs } from '../../services/siteFeatures.js'
/**
* Webshop im Ticketsystem (Kunden-Reiter "Funktionen"): Stand je Projekt und Entscheidung ueber die Anfrage.
* Eingehaengt in routes/team/index.js unter /api/team/shop, dort hinter requireStaffJwt.
*/
const router = Router()
const sendError = (res, err) => {
const status = err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[team-shop]', err.message)
return res.status(status).json({ error: err.message || 'Interner Serverfehler', ...(err.body?.code ? { code: err.body.code } : {}) })
}
const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err))
const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' })
/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */
async function zaehle(collection, queries, was) {
try {
return await countDocuments(config.collections[collection], queries)
} catch (err) {
console.warn(`[team-shop] ${was} nicht lesbar: ${err.message}`)
return null
}
}
/** stripeStatus (JSON-Text im shopConfig) als Objekt oder null, wenn leer oder nicht lesbar. */
function stripeStatus(text) {
try {
const roh = typeof text === 'string' && text ? JSON.parse(text) : null
return roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null
} catch {
return null
}
}
async function projektStand(project, feature) {
let shop = null
if (feature) {
try {
shop = await leseShopConfig(feature.$id)
} catch (err) {
console.warn(`[team-shop] shopConfig ${feature.$id} nicht lesbar: ${err.message}`)
}
}
const shopId = feature?.$id
const [artikel, offeneBestellungen] = shopId
? await Promise.all([
zaehle('shopProducts', [Query.equal('shopId', shopId)], 'Artikelzahl'),
zaehle('shopOrders', [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')], 'Bestellzahl'),
])
: [0, 0]
return {
projectId: project.$id,
projectName: project.projectName || '',
state: feature?.state || 'off',
anfrage: shop ? anfrageAnsicht(shop) : null,
stripe: { accountId: Boolean(shop?.stripeAccountId), status: stripeStatus(shop?.stripeStatus) },
geoeffnet: shop?.geoeffnet === true,
zahlen: { artikel, offeneBestellungen },
}
}
// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Shop-Dokument existiert
router.get('/:customerId', route(async (req, res) => {
const customerId = req.params.customerId
const [docs, projects] = await Promise.all([
listCustomerFeatureDocs(customerId),
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
])
const shopDocs = new Map(docs.filter((d) => d.featureKey === 'shop').map((d) => [d.projectId, d]))
const sichtbar = projects.filter((p) => !p.archived || shopDocs.has(p.$id))
res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, shopDocs.get(p.$id)))) })
}))
router.post('/:customerId/anfrage', route(async (req, res) => {
const { projectId, entscheidung, begruendung } = req.body || {}
const ergebnis = await entscheideAnfrage({
customerId: req.params.customerId,
projectId,
entscheidung,
begruendung,
person: person(req),
})
res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) })
}))
export default router
+77 -2
View File
@@ -1,7 +1,7 @@
import { config } from '../../config.js'
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendAdminNotice } from '../mailer.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
import { shopFuerKunde } from './schalter.js'
@@ -28,6 +28,9 @@ const MELDUNG = {
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.',
}
@@ -47,7 +50,7 @@ export async function anfrageOffenePunkte({ customerId, projectId }) {
}
/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */
async function leseShopConfig(id) {
export async function leseShopConfig(id) {
try {
return await getDocument(config.collections.shopConfig, id)
} catch (err) {
@@ -230,3 +233,75 @@ export async function zieheZurueck({ customerId, projectId, person = {} }) {
})
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
}
}
+34 -2
View File
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20', 'T21']
const EXPECTED = [...ids('G', 8), ...ids('K', 24), ...ids('T', 19), 'T20', 'T21']
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -1041,3 +1041,35 @@ test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fal
for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`)
}
})
// Webshop B2: K24 meldet dem Kunden die Entscheidung des Teams über seine Webshop-Anfrage (Block angenommen oder abgelehnt)
const K24_WERTE = { kunde_name: 'Erika', projekt: 'Töpferei', preis: '299 €', ust_hinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', begruendung: 'Wir verkaufen keine <Waffen>.\nBitte melde Dich.', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K24: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const tpl = loadTemplate('K24')
assert.equal(tpl.file, 'K24 Webshop-Anfrage.html')
assert.equal(tpl.subject, 'Deine Webshop-Anfrage für {{projekt}}')
assert.deepEqual(tpl.placeholders, ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'])
assert.deepEqual(tpl.blocks, ['angenommen', 'abgelehnt'])
})
test('K24 angenommen: Preis mit Umsatzsteuerhinweis, Knopf ins Portal, keine Begründung', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: true, abgelehnt: false })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Webshop-Anfrage für Töpferei')
assert.match(mail.text, /Hallo Erika/)
assert.match(mail.text, /angenommen/)
assert.match(mail.text, /299 €/)
assert.match(mail.text, /§ 19 UStG/)
assert.doesNotMatch(mail.text, /Waffen|Begründung|leider/)
assert.match(mail.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
})
test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Preis', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: false, abgelehnt: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /leider/)
assert.match(mail.text, /Wir verkaufen keine <Waffen>\./)
assert.match(mail.html, /Wir verkaufen keine &lt;Waffen&gt;\.<br>Bitte melde Dich\./)
assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/)
})
+254
View File
@@ -0,0 +1,254 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Webshop B2: Team-Uebersicht und Entscheidung ueber die Anfrage (routes/team/shop.js, services/shop/anfrage.js entscheideAnfrage), K24
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, post } = await import('./support/testApp.js')
const { seedFixtures, customers } = await import('./support/fixtures.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const anfrage = await import('../server/services/shop/anfrage.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { featureHaken } = await import('../server/services/featureRegistry.js')
const { ensureFeatureDoc } = await import('../server/services/siteFeatures.js')
const { resetShopRoutesForTests } = await import('../server/routes/shop.js')
const app = await startTestApp({ '/api/team': teamRoutes })
const mails = mailSink()
mails.versandFehler = false
const sinkMail = mails.sendMail.bind(mails)
mails.sendMail = async (mail) => {
if (mails.versandFehler && /Webshop-Anfrage/.test(mail.subject)) throw new Error('SMTP nicht erreichbar')
return sinkMail(mail)
}
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Töpferei' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Altprojekt', archived: true })
aw.seed('websiteProjects', { $id: 'pt5', customerId: 'c1', projectName: 'Altshop', archived: true })
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
const GUELTIG = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik und Tassen aus eigener Werkstatt.',
artikelzahl: 'bis20',
versand: true,
abholung: false,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: '',
}
const person = { name: 'Kunde Eins', email: customers.c1.email }
const stelle = (projectId = 'pt1') => anfrage.stelleAnfrage({ customerId: 'c1', projectId, eingabe: GUELTIG, person })
const entscheide = (body, opts = staff, customerId = 'c1') => post(`/api/team/shop/${customerId}/anfrage`, { projectId: 'pt1', ...body }, opts)
const k24 = () => mails.sent.filter((m) => /Webshop-Anfrage/.test(m.subject))
const shopDoc = (projectId = 'pt1') => [...aw.docs('shopConfig').values()].find((d) => d.projectId === projectId) ?? null
const uebersicht = async (customerId = 'c1') => (await rawGet(`/api/team/shop/${customerId}`, staff)).body.projects
let origin
beforeEach(() => {
origin ??= { mode: config.shop.mode, pilotEmails: config.shop.pilotEmails }
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
mails.versandFehler = false
resetShopRoutesForTests()
})
async function leereAlles() {
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders']) {
for (const id of aw.docs(col).keys()) await deleteDocument(config.collections[col], id)
}
mails.sent.length = 0
}
test('ohne Anmeldung 401, mit Mitarbeiter-JWT erreichbar', async () => {
assert.equal((await rawGet('/api/team/shop/c1')).status, 401)
assert.equal((await post('/api/team/shop/c1/anfrage', { projectId: 'pt1', entscheidung: 'annehmen' })).status, 401)
assert.equal((await rawGet('/api/team/shop/c1', staff)).status, 200)
})
test('GET: Projekte des Kunden ohne Shop, archivierte nur mit Shop-Dokument', async () => {
await leereAlles()
const ohne = await uebersicht()
assert.deepEqual(ohne.map((p) => p.projectId).sort(), ['pt1', 'pt2'])
assert.deepEqual(ohne[0], {
projectId: ohne[0].projectId,
projectName: ohne[0].projectName,
state: 'off',
anfrage: null,
stripe: { accountId: false, status: null },
geoeffnet: false,
zahlen: { artikel: 0, offeneBestellungen: 0 },
})
assert.deepEqual(await uebersicht('c3'), [])
await ensureFeatureDoc('c1', 'pt5', 'shop')
assert.deepEqual((await uebersicht()).map((p) => p.projectId).sort(), ['pt1', 'pt2', 'pt5'])
})
test('GET: Anfrage, Stripe-Stand, geoeffnet und Zahlen je Projekt', async () => {
await leereAlles()
await stelle('pt1')
const doc = shopDoc('pt1')
await updateDocument(config.collections.shopConfig, doc.$id, {
stripeAccountId: 'acct_1',
stripeStatus: JSON.stringify({ chargesEnabled: true, detailsSubmitted: true }),
geoeffnet: true,
})
for (const [id, shopId, status] of [['o1', doc.$id, 'bezahlt'], ['o2', doc.$id, 'bezahlt'], ['o3', doc.$id, 'offen'], ['o4', doc.$id, 'versendet'], ['o5', 'anderer', 'bezahlt']]) {
aw.seed('shopOrders', { $id: id, shopId, customerId: 'c1', status })
}
aw.seed('shopProducts', { $id: 'a1', shopId: doc.$id, customerId: 'c1', name: 'Tasse' })
aw.seed('shopProducts', { $id: 'a2', shopId: doc.$id, customerId: 'c1', name: 'Teller' })
const p1 = (await uebersicht()).find((p) => p.projectId === 'pt1')
assert.equal(p1.anfrage.status, 'offen')
assert.equal(p1.anfrage.anfrage.warenart, 'kunst')
assert.deepEqual(p1.stripe, { accountId: true, status: { chargesEnabled: true, detailsSubmitted: true } })
assert.equal(p1.geoeffnet, true)
assert.deepEqual(p1.zahlen, { artikel: 2, offeneBestellungen: 2 })
const p2 = (await uebersicht()).find((p) => p.projectId === 'pt2')
assert.equal(p2.anfrage, null)
await updateDocument(config.collections.shopConfig, doc.$id, { stripeStatus: '{kaputt' })
assert.equal((await uebersicht()).find((p) => p.projectId === 'pt1').stripe.status, null, 'unlesbares JSON => null')
})
test('annehmen: speichert Entscheidung und Person, K24 an den Kunden, Bestell-Haken liefert []', async () => {
await leereAlles()
await stelle()
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT])
mails.sent.length = 0
const res = await entscheide({ entscheidung: 'annehmen', begruendung: 'wird ignoriert' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.mailFehler, undefined)
assert.equal(res.body.anfrage.status, 'angenommen')
assert.ok(res.body.anfrage.entschiedenAt)
assert.equal(res.body.anfrage.begruendung, '')
const doc = shopDoc()
assert.equal(doc.anfrageStatus, 'angenommen')
assert.equal(doc.anfrageEntschiedenVon, 'Andrej <andrej@webklar.com>')
assert.equal(doc.anfrageBegruendung, '')
assert.equal(doc.version, 2)
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [])
const mail = k24()
assert.equal(mail.length, 1)
assert.equal(mail[0].to[0].address, customers.c1.email)
assert.equal(mail[0].subject, 'Deine Webshop-Anfrage für Töpferei')
assert.match(mail[0].text, /Hallo Kunde Eins/)
assert.match(mail[0].text, /angenommen/)
assert.match(mail[0].text, /299 €/)
assert.match(mail[0].text, /§ 19 UStG/)
assert.doesNotMatch(mail[0].text, /leider/)
assert.match(mail[0].html, /dashboard\.html#webshop\?projekt=pt1"/)
assert.ok(mail[0].html.includes(config.publicBaseUrl))
})
test('ablehnen: Begründung Pflicht (1-1000), gespeichert und in K24', async () => {
await leereAlles()
await stelle()
mails.sent.length = 0
for (const begruendung of [undefined, '', ' ', 5, 'x'.repeat(1001)]) {
const res = await entscheide({ entscheidung: 'ablehnen', begruendung })
assert.equal(res.status, 400, String(begruendung).slice(0, 10))
}
assert.equal(shopDoc().anfrageStatus, 'offen')
assert.equal(k24().length, 0)
const res = await entscheide({ entscheidung: 'ablehnen', begruendung: ` ${'y'.repeat(1000)} ` })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.anfrage.status, 'abgelehnt')
assert.equal(res.body.anfrage.begruendung, 'y'.repeat(1000))
assert.equal(shopDoc().anfrageStatus, 'abgelehnt')
assert.deepEqual(await featureHaken('shop').bestellpruefung({ customerId: 'c1', projectId: 'pt1' }), [anfrage.ANFRAGE_OFFEN_TEXT])
assert.match(k24()[0].text, /leider/)
assert.match(k24()[0].text, /yyyy/)
assert.doesNotMatch(k24()[0].text, /299 €/)
})
test('falscher Zustand 409, fremdes Projekt 403, ungültige Eingabe 400', async () => {
await leereAlles()
// kein Dokument
let res = await entscheide({ entscheidung: 'annehmen' })
assert.equal(res.status, 409)
assert.equal(res.body.code, 'nicht_offen')
await stelle()
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
mails.sent.length = 0
// schon entschieden: weder annehmen noch ablehnen
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409)
assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Grund' })).status, 409)
assert.equal(shopDoc().anfrageStatus, 'angenommen')
// zurückgezogen
await stelle('pt2')
await anfrage.zieheZurueck({ customerId: 'c1', projectId: 'pt2', person })
assert.equal((await entscheide({ projectId: 'pt2', entscheidung: 'annehmen' })).status, 409)
assert.equal(k24().length, 0, 'bei 409 keine Mail')
assert.equal((await entscheide({ projectId: 'pt3', entscheidung: 'annehmen' })).status, 403, 'Projekt eines anderen Kunden')
assert.equal((await entscheide({ projectId: 'gibtsnicht', entscheidung: 'annehmen' })).status, 403)
assert.equal((await entscheide({ projectId: undefined, entscheidung: 'annehmen' })).status, 400)
assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400)
assert.equal((await entscheide({ entscheidung: undefined })).status, 400)
assert.equal((await entscheide({ entscheidung: 'annehmen' }, staff, 'c2')).status, 403, 'Projekt gehört nicht zum Kunden der URL')
})
test('Mailfehler: Entscheidung bleibt, Antwort trägt mailFehler', async () => {
await leereAlles()
await stelle()
mails.versandFehler = true
const res = await entscheide({ entscheidung: 'annehmen' })
assert.equal(res.status, 200)
assert.equal(res.body.mailFehler, true)
assert.equal(res.body.anfrage.status, 'angenommen')
assert.equal(shopDoc().anfrageStatus, 'angenommen')
assert.equal(k24().length, 0)
})
test('zwei gleichzeitige Entscheidungen: genau eine gewinnt, genau eine Mail', async () => {
await leereAlles()
await stelle()
mails.sent.length = 0
const [a, b] = await Promise.all([entscheide({ entscheidung: 'annehmen' }), entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' })])
assert.deepEqual([a.status, b.status].sort(), [200, 409])
assert.equal(k24().length, 1)
assert.equal(shopDoc().anfrageStatus, a.status === 200 ? 'angenommen' : 'abgelehnt')
})
test('nach Ablehnung kann der Kunde neu anfragen, die alte Entscheidung ist gelöscht', async () => {
await leereAlles()
await stelle()
await entscheide({ entscheidung: 'ablehnen', begruendung: 'Nein' })
await stelle()
const doc = shopDoc()
assert.equal(doc.anfrageStatus, 'offen')
assert.equal(doc.anfrageBegruendung, '')
assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
})