diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 745c793..e493613 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { aboMemo } from '../../services/aboStatus.js' import { validateTextform } from '../../services/analyse.js' -import { getDocument } from '../../services/appwriteAdmin.js' +import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' import { featureConfig } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js' @@ -67,6 +67,26 @@ async function customerName(customerId) { } } +const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden' + +/** + * Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die + * Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im + * Log, die Antwort traegt die Warnung trotzdem. + */ +async function notiereK16Fehlt(projectId, doc) { + try { + await withLocks([`feature-admin:${projectId}`], async () => { + const frisch = await getDocument(config.collections.portalFeatures, doc.$id) + await updateDocument(config.collections.portalFeatures, doc.$id, { + adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }), + }) + }) + } catch (err) { + console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message) + } +} + const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null /** @@ -231,9 +251,14 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { await disable(doc.$id, { by, adminNote }) } }) - // Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen - if (freigeschaltet) await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }) - res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) }) + // Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen. + // Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach + let warnung = false + if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) { + warnung = true + await notiereK16Fehlt(projectId, freigeschaltet.doc) + } + res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), ...(warnung ? { warnung } : {}) }) } catch (err) { sendError(res, err) } diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index ee8cd03..26fc88b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -438,7 +438,10 @@ function assertReason(reason) { */ export async function disable(docId, { by, adminNote, reason = 'team' } = {}) { assertReason(reason) - const doc = await withLocks([`feature-disable:${docId}`], async () => { + // Dieselbe Sperre wie die Freischaltung (activateClosedTickets, freischaltenNachAbnahme): beide schreiben den Zustand desselben Dokuments und + // duerfen sich nicht ueberholen. Kein Halter von feature-activate: ruft disable() auf; die Reihenfolge feature-admin -> feature-activate + // besteht schon. Keine Mehrfach-Sperre (withLocks mit mehreren Schluesseln kann sich verklemmen). + const doc = await withLocks([`feature-activate:${docId}`], async () => { const current = await getDocument(config.collections.portalFeatures, docId) if (current.state === 'disabled') { if (current.disabledAt) return current @@ -1080,6 +1083,13 @@ async function unmatchedNotice(session) { async function adoptPaidSession(session) { const { customerId, projectId, featureKey } = session.metadata || {} const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null + // Erneut zugestelltes Ereignis ("Resend event" in Stripe) zu einer Bestellung, die laengst eingerichtet, abgeschaltet und nach 30 Tagen + // geloescht wurde (off, purchaseStatus 'zurueckgesetzt', dieselbe Sitzung): sie ist ausgeliefert, nichts wird neu eingerichtet und + // niemand benachrichtigt (kein Ticket, keine K15, kein T16). + if (doc?.state === 'off' && doc.purchaseStatus === 'zurueckgesetzt' && doc.stripeSessionId === session.id) { + console.warn(`[funktionen] Sitzung ${session.id} gehoert zu einer Funktion, die nach dem Abo-Ende schon geloescht und zurueckgesetzt wurde - nicht uebernommen, nichts gesendet`) + return false + } if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) { console.error( `[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen` diff --git a/server/services/termine/freischaltung.js b/server/services/termine/freischaltung.js index 126660f..9c1cc99 100644 --- a/server/services/termine/freischaltung.js +++ b/server/services/termine/freischaltung.js @@ -40,18 +40,23 @@ export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) { * Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail, * die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler. * attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a). + * Liefert true, wenn K16 rausging; false, wenn nicht (Fehler, Kunde ohne Adresse oder unbekannt). Nur die Staff-Aktivierung mit + * Unterlagen wertet das aus (sie muss die Unterlagen dann von Hand nachsenden); alle anderen Aufrufer ignorieren den Wert wie bisher. * @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument * @param {{ attachments?: object[] }} [optionen] + * @returns {Promise} */ export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) { try { const customer = await customerOf(doc.customerId) - if (!customer?.email) return + if (!customer?.email) return false const feature = featureConfig(doc.featureKey) if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) await sendCustomerMail(customer.email, { ...mail, attachments }) + return true } catch (err) { console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + return false } } diff --git a/test/featureFreischaltung.test.js b/test/featureFreischaltung.test.js index cb62441..9f3dc28 100644 --- a/test/featureFreischaltung.test.js +++ b/test/featureFreischaltung.test.js @@ -370,6 +370,19 @@ test('Der Haken bekommt das unter der Sperre frisch gelesene Dokument; ein zwisc assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument') }) +test('A3: disable() teilt die Sperre feature-activate: mit der Freischaltung - wer die Freischaltung haelt, laesst das Abschalten warten', async () => { + const id = einrichtung('termine', { einstellung: true }) + let abschalten + await withLocks([`feature-activate:${id}`], async () => { + abschalten = sf.disable(id, { reason: 'team' }) + await new Promise((r) => setTimeout(r, 80)) + assert.equal(zustand(id), 'setup', 'solange die Freischaltung die Sperre haelt, schaltet disable() nicht ab') + }) + const doc = await abschalten + assert.equal(doc.state, 'disabled') + assert.equal(zustand(id), 'disabled') +}) + // ------------------------------------------------------------------------------- Tickets, Kunden --- test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => { let gefragt = 0 diff --git a/test/featurePaid.test.js b/test/featurePaid.test.js index a4f790e..fbb76a0 100644 --- a/test/featurePaid.test.js +++ b/test/featurePaid.test.js @@ -372,6 +372,37 @@ test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, e assert.equal(mails.sent.length, 2) }) +test('(7) A1: erneut zugestelltes checkout.session.completed nach der 30-Tage-Loeschung (off, zurueckgesetzt, gleiche Sitzung) wird nicht uebernommen', async (t) => { + const sessionId = await bestelle() + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + // der 30-Tage-Lauf hat das Dokument zurueckgesetzt (siehe setzeFunktionZurueck); die Sitzungs-ID bleibt als Marke stehen + const d0 = await doc() + aw.seed('portalFeatures', { + ...d0, + state: 'off', + enabled: false, + unlockedByPurchase: false, + purchaseStatus: 'zurueckgesetzt', + zahlungMeldung: '', + ticketId: '', + adminNote: 'Daten gelöscht (Löschfrist abgelaufen)', + }) + const tickets0 = tickets().length + const mails0 = mails.sent.length + const stumm = STUMM(t) + const warn = t.mock.method(console, 'warn', () => {}) + + // "Resend event" im Stripe-Dashboard: dasselbe Ereignis noch einmal + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + const d = await doc() + assert.deepEqual([d.state, d.purchaseStatus, d.stripeSessionId, d.ticketId], ['off', 'zurueckgesetzt', sessionId, ''], 'nicht uebernommen') + assert.equal(tickets().length, tickets0, 'kein neues Ticket') + assert.equal(mails.sent.length, mails0, 'weder K15 noch T16') + const log = geloggt(stumm) + warn.mock.calls.map((c) => c.arguments.join(' ')).join('\n') + assert.match(log, new RegExp(`Sitzung ${sessionId} .*zurueckgesetzt`)) + assert.doesNotMatch(log, /k@example\.invalid|Salon/, 'keine personenbezogenen Daten im Log') +}) + // ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf --- test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => { const alt = await bestelle() diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 53c53c9..f12d12d 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -1444,6 +1444,44 @@ test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Na assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts') }) +const K16_NICHT_GESENDET = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden' + +test('textform A2: scheitert der Versand von K16 mit den Unterlagen, bleibt die Funktion freigeschaltet, adminNote und warnung:true sagen es; Erfolg und Kunde ohne Adresse', async (t) => { + modusMerken(t) + termineMit('on', []) + t.mock.method(console, 'error', () => {}) + + // Erfolg: keine Warnung, keine Notiz + const gut = legeKundeF13() + const ok = await aktivierenF13(gut, feldF13(gut)) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.equal(ok.body.warnung, undefined, 'ohne Fehler kein Feld warnung') + assert.doesNotMatch((await sf.getFeatureDoc(gut.customerId, gut.projectId, 'termine')).adminNote, /NICHT gesendet/) + + // Versandfehler: Freischaltung und Nachweis stehen, die Warnung kommt zurueck + const k = legeKundeF13() + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) + t.after(() => setMailTransportForTests(mails)) + const res = await aktivierenF13(k, feldF13(k)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.warnung, true) + const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine') + assert.equal(doc.state, 'active', 'die Freischaltung bleibt') + assert.equal(consentsDesF13(k.projectId).length, 1, 'der Nachweis bleibt') + assert.ok(doc.adminNote.includes(K16_NICHT_GESENDET), doc.adminNote) + assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z System: K16 mit Unterlagen NICHT gesendet – von Hand nachsenden \| .*Andrej: activate/) + assert.equal(featuresOf(res, k.projectId).termine.adminNote, doc.adminNote, 'das Ticketsystem liest die Notiz mit der Antwort') + + // Kunde ohne E-Mail-Adresse: K16 kann nicht raus, auch das ist eine Warnung + setMailTransportForTests(mails) + const leer = legeKundeF13({ email: '' }) + aw.docs('customers').get(leer.customerId).email = '' + const ohneAdresse = await aktivierenF13(leer, feldF13(leer)) + assert.equal(ohneAdresse.status, 200, JSON.stringify(ohneAdresse.body)) + assert.equal(ohneAdresse.body.warnung, true) + assert.ok((await sf.getFeatureDoc(leer.customerId, leer.projectId, 'termine')).adminNote.includes(K16_NICHT_GESENDET)) +}) + test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => { modusMerken(t) const mailsVorher = mails.sent.length diff --git a/test/termineFreischaltung.test.js b/test/termineFreischaltung.test.js index 7bff585..8037628 100644 --- a/test/termineFreischaltung.test.js +++ b/test/termineFreischaltung.test.js @@ -315,6 +315,31 @@ test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register => assert.equal(mails.sent.length, 0) }) +test('meldeFreigeschaltet liefert true nur, wenn K16 rausging: Erfolg true; Versandfehler, nicht lesbarer Kunde, unbekannte Funktion, Kunde ohne oder unbekannt false (A2)', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await meldeFreigeschaltet(doc), true) + assert.equal(mails.sent.length, 1) + + setMailTransportForTests({ + sendMail: async () => { + throw new Error('SMTP: Postfach voll') + }, + }) + try { + assert.equal(await meldeFreigeschaltet(doc), false, 'Versandfehler') + } finally { + setMailTransportForTests(mails) + } + aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' }) + assert.equal(await meldeFreigeschaltet(doc), false, 'Kunde nicht lesbar') + assert.equal(await meldeFreigeschaltet(einrichtung('gibtsnicht')), false, 'Funktion nicht im Register') + aw.seed('customers', { $id: 'c_leer2', appwriteUserId: 'u_leer2', email: '', name: 'Ohne Adresse', status: 'active' }) + assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_leer2' })), false, 'Kunde ohne Adresse') + assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_gibtsnicht' })), false, 'Kunde unbekannt') + assert.equal(mails.sent.length, 1, 'nur die erste Mail ging raus') +}) + // -------------------------------------------------------------------------------------------- runActivation --- test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => { const doc = einrichtung('termine', { einstellung: true })