diff --git a/server/mail-templates/K24 Webshop-Anfrage.html b/server/mail-templates/K24 Webshop-Anfrage.html new file mode 100644 index 0000000..11a2dc6 --- /dev/null +++ b/server/mail-templates/K24 Webshop-Anfrage.html @@ -0,0 +1,52 @@ + + + + + + + + +Deine Webshop-Anfrage für {{projekt}} + + + + +Deine Webshop-Anfrage wurde bearbeitet. ͏ ͏ ͏ + + + + + diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 9c38be4..5c803fc 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -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 diff --git a/server/routes/team/shop.js b/server/routes/team/shop.js new file mode 100644 index 0000000..2480ea4 --- /dev/null +++ b/server/routes/team/shop.js @@ -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 diff --git a/server/services/shop/anfrage.js b/server/services/shop/anfrage.js index 196835f..81412ac 100644 --- a/server/services/shop/anfrage.js +++ b/server/services/shop/anfrage.js @@ -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 ' (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 + } +} diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index da9d5da..c909cbf 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -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 .\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 \./) + assert.match(mail.html, /Wir verkaufen keine <Waffen>\.
Bitte melde Dich\./) + assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/) +}) diff --git a/test/shopTeam.test.js b/test/shopTeam.test.js new file mode 100644 index 0000000..d36a76e --- /dev/null +++ b/test/shopTeam.test.js @@ -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 ') + 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) +})