From 9dd02445373883db3f9e9b1c158ba25c88ee3930 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:48:02 +0000 Subject: [PATCH] Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung markPaid holt Betreff und Details des Einrichtungs-Tickets aus dem Register (ticketTexte), createFeatureTicket bleibt die einzige Stelle, die das Ticket anlegt. Betreff auf 128, Details auf 7000 Zeichen gekuerzt (surrogatsicher, Schnitt an der Zeilengrenze, einheitliche Schlusszeile). Wirft die Details-Funktion oder liefert sie keinen Text, gelten die Notfall-Details (Kopfzeilen und Verweis ins WOMS). Nach einem 400 bei langen Details gibt es genau einen zweiten Versuch mit den Notfall-Details und derselben woid; 5xx, Netzfehler und kurze Details werden ohne Wiederholung weitergereicht. Stub termine/ticketDetails.js bis Task D4. Co-Authored-By: Claude Sonnet 5.5 --- server/services/featureRegistry.js | 103 +++++ server/services/siteFeatures.js | 6 +- server/services/termine/ticketDetails.js | 10 + server/services/ticketAdmin.js | 73 +++- test/featureTicket.test.js | 506 +++++++++++++++++++++++ 5 files changed, 678 insertions(+), 20 deletions(-) create mode 100644 server/services/termine/ticketDetails.js create mode 100644 test/featureTicket.test.js diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index a169fda..6547502 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -116,3 +116,106 @@ export function oeffentlicheAnsicht(key) { requiresWartungsAbo: e.requiresWartungsAbo === true, } } + +// --------------------------------------------------------------------------------------------------------------- +// Einrichtungs-Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel und Notfall-Details. +// createFeatureTicket (ticketAdmin.js) ist die einzige Stelle, die das Ticket anlegt; markPaid holt die Texte hier. +// --------------------------------------------------------------------------------------------------------------- + +/** Laengendeckel der Ticket-Felder (workorders.topic / workorders.details). */ +export const TICKET_TOPIC_MAX = 128 +export const TICKET_DETAILS_MAX = 7000 +/** Ab dieser Laenge gelten Details als "lang": nur dann wiederholt createFeatureTicket nach einem 400 mit den Notfall-Details. */ +export const LANGE_DETAILS = 1000 + +/** Einheitliche Schlusszeile eines gekuerzten Ticket-Textes. */ +export const TICKET_GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen' + +const istHighSurrogat = (code) => code >= 0xd800 && code <= 0xdbff + +/** + * Text auf hoechstens max Zeichen (UTF-16-Einheiten, wie string.length) kuerzen, surrogatsicher: nie ein halbes Zeichenpaar. + * Passt der Text, bleibt er unveraendert (ohne endzeile). Sonst wird so viel Text behalten, dass mit der Endzeile genau max + * nicht ueberschritten wird: anZeile schneidet an der letzten Zeilengrenze (ohne Zeilengrenze im Platz: harter Schnitt) und + * setzt die Endzeile in eine eigene Zeile; ohne anZeile haengt die Endzeile direkt an den Schnitt. + */ +export function kuerze(text, max, { anZeile = false, endzeile = '' } = {}) { + const s = typeof text === 'string' ? text : String(text ?? '') + if (s.length <= max) return s + const trenner = anZeile && endzeile ? '\n' : '' + const platz = max - endzeile.length - trenner.length + const hartSchnitt = (n) => { + let teil = s.slice(0, Math.max(0, n)) + if (teil && istHighSurrogat(teil.charCodeAt(teil.length - 1))) teil = teil.slice(0, -1) + return teil + } + if (platz <= 0) return hartSchnitt(max) + let teil + if (anZeile) { + const grenze = s.lastIndexOf('\n', platz) + teil = grenze > 0 ? s.slice(0, grenze) : hartSchnitt(platz) + } else { + teil = hartSchnitt(platz) + } + teil = teil.trimEnd() + return teil ? `${teil}${trenner}${endzeile}` : endzeile +} + +/** Link ins WOMS-Kundenpanel "Funktionen" mit geoeffneter Einbauanleitung des Projekts (Ticket, T16, Anleitungs-Route). */ +export function anleitungsLink(customerId, projectId) { + return `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId ?? '')}?tab=funktionen&anleitung=${encodeURIComponent(projectId ?? '')}` +} + +const projektName = (project) => project?.projectName || project?.subdomain || '' + +/** + * Notfall-Details des Tickets: Kopfzeilen (Funktion, Projekt, Domain, Kunde, siteKey) und ein Verweis auf die Anleitung im + * WOMS. Ohne Rechenarbeit und ohne Dienste, damit es auch dann klappt, wenn die Anleitung des Registers nicht entsteht. + * ctx = { feature, doc, project, customer } (customer best effort oder null). + */ +export function minimaleTicketDetails(ctx) { + const { feature, doc, project, customer } = ctx || {} + const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || '' + return [ + `Funktion: ${feature?.key || doc?.featureKey || ''}`, + `Projekt: ${projektName(project)}`, + `Domain: ${domain}`, + `Kunde: ${customer?.name || doc?.customerId || ''}`, + `siteKey: ${doc?.siteKey || ''}`, + `Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`, + ].join('\n') +} + +/** + * Texte des Einrichtungs-Tickets einer Funktion aus dem Register: { topic, details, fallbackDetails }. + * topic auf TICKET_TOPIC_MAX, details auf TICKET_DETAILS_MAX gekuerzt (Schnitt an der letzten Zeilengrenze, eine + * einheitliche Schlusszeile). Wirft feature.ticket.details oder liefert es Leeres/Nicht-Text, gilt fallbackDetails + * (minimaleTicketDetails): bezahlt ist bezahlt, das Ticket entsteht immer. + */ +export async function ticketTexte(ctx) { + const { feature, project } = ctx || {} + const kuerzeDetails = (text) => kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) + const fallbackDetails = kuerzeDetails(minimaleTicketDetails(ctx)) + + let topic = '' + try { + topic = feature.ticket.topic(project) + } catch (err) { + console.error(`[funktionen] Ticket-Betreff (${feature?.key}) nicht berechenbar:`, err.message) + } + if (typeof topic !== 'string' || !topic.trim()) topic = `${feature?.label?.name || 'Funktion'} einbauen – ${projektName(project)}`.trim() + + let details = '' + try { + details = await feature.ticket.details(ctx) + } catch (err) { + console.error(`[funktionen] Ticket-Details (${feature?.key}) nicht berechenbar, Notfall-Details:`, err.message) + } + if (typeof details !== 'string' || !details.trim()) details = fallbackDetails + + return { + topic: kuerze(topic.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }), + details: kuerzeDetails(details), + fallbackDetails, + } +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index bf43684..edacfc0 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -4,7 +4,7 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' -import { featureConfig, featureKeys } from './featureRegistry.js' +import { featureConfig, featureKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' import { createFeatureTicket } from './ticketAdmin.js' @@ -293,11 +293,15 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { invoiceUrl: invoiceUrl || '', }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) + const customer = await customerOf(doc.customerId).catch(() => null) + const feature = featureConfig(doc.featureKey) + const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {} const { ticketId } = await createFeatureTicket({ customerId: doc.customerId, project, featureKey: doc.featureKey, siteKey: doc.siteKey, + ...texte, }) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) return { doc: updated, created: true } diff --git a/server/services/termine/ticketDetails.js b/server/services/termine/ticketDetails.js new file mode 100644 index 0000000..bef1032 --- /dev/null +++ b/server/services/termine/ticketDetails.js @@ -0,0 +1,10 @@ +// STUB (Block B) – ersetzt Task D4 +import { minimaleTicketDetails } from '../featureRegistry.js' + +/** + * Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details). + * ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben. + */ +export async function termineTicketDetails(ctx) { + return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.` +} diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js index 866452b..7113298 100644 --- a/server/services/ticketAdmin.js +++ b/server/services/ticketAdmin.js @@ -9,6 +9,7 @@ import { Query, ID, } from './appwriteAdmin.js' +import { kuerze, LANGE_DETAILS, TICKET_DETAILS_MAX, TICKET_GEKUERZT, TICKET_TOPIC_MAX } from './featureRegistry.js' const WOID_BASE = 9999 @@ -69,30 +70,33 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) { } /** - * Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite. + * Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand, Terminplaner) einer Kundenwebsite. * Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei. + * Einzige Stelle, die dieses Ticket anlegt. topic und details sind optional (ohne: die News-Festwerte); markPaid uebergibt + * die Registertexte (ticketTexte). Beides wird auf TICKET_TOPIC_MAX / TICKET_DETAILS_MAX gekuerzt. + * Wiederholung nur, wenn das Anlegen mit 400 scheitert, fallbackDetails uebergeben wurde und die Details lang waren + * (LANGE_DETAILS): dann einmal mit fallbackDetails und derselben woid. Jeder andere Fehler (5xx, Netz: unklar, ob das Ticket + * entstand) wird ohne Wiederholung weitergereicht, sonst gaebe es Doppel-Tickets; scheitert auch der Rueckfall, wird der erste + * Fehler geworfen. */ -export async function createFeatureTicket({ customerId, project, featureKey, siteKey, topic = null, details = null }) { +export async function createFeatureTicket({ + customerId, + project, + featureKey, + siteKey, + topic = null, + details = null, + fallbackDetails = null, +}) { const customer = await getDocument(config.collections.customers, customerId).catch(() => null) const woid = await generateNextWoid() const projectName = project?.projectName || project?.subdomain || '' const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || '' const embed = `
+ https://project.webklar.com/embed/news.js` - const ticket = await createDocument( - config.collections.workorders, - { - topic: topic || `News-Pinnwand einbauen – ${projectName}`.trim(), - type: 'Webpage', - status: 'Open', - priority: 1, - serviceType: 'Remote', - systemType: 'n/a', - customerId, - customerName: customer?.name || '', - customerLocation: customer?.location || '', - woid, - details: details || [ + const text = kuerze( + details || + [ `Funktion: ${featureKey}`, `Projekt: ${projectName}`, `Domain: ${domain}`, @@ -100,10 +104,41 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit `Einbau: ${embed}`, 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', ].join('\n'), - createdAt: new Date().toISOString(), - }, - ID.unique() + TICKET_DETAILS_MAX, + { anZeile: true, endzeile: TICKET_GEKUERZT } ) + const fields = { + topic: kuerze(topic || `News-Pinnwand einbauen – ${projectName}`.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }), + type: 'Webpage', + status: 'Open', + priority: 1, + serviceType: 'Remote', + systemType: 'n/a', + customerId, + customerName: customer?.name || '', + customerLocation: customer?.location || '', + woid, + createdAt: new Date().toISOString(), + } + + let ticket + try { + ticket = await createDocument(config.collections.workorders, { ...fields, details: text }, ID.unique()) + } catch (firstError) { + const hatRueckfall = typeof fallbackDetails === 'string' && fallbackDetails !== '' + if (firstError?.status !== 400 || !hatRueckfall || text.length <= LANGE_DETAILS) throw firstError + console.warn(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) abgelehnt (400), zweiter Versuch mit Notfall-Details:`, firstError.message) + try { + ticket = await createDocument( + config.collections.workorders, + { ...fields, details: kuerze(fallbackDetails, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) }, + ID.unique() + ) + } catch (secondError) { + console.error(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}): auch der zweite Versuch scheiterte:`, secondError.message) + throw firstError + } + } console.log(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) fuer Projekt ${project?.$id} angelegt`) return { ticketId: ticket.$id, woid } } diff --git a/test/featureTicket.test.js b/test/featureTicket.test.js new file mode 100644 index 0000000..bcf464b --- /dev/null +++ b/test/featureTicket.test.js @@ -0,0 +1,506 @@ +import { test, after } 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' + +// Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel, Notfall-Details und gezielte Wiederholung. +// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau. +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { createFeatureTicket } = await import('../server/services/ticketAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => setMailTransportForTests(null)) + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' }) + +const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen' +const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? [...aw.docs('workorders').values()] +const zeilen = (n, breite = 49) => Array.from({ length: n }, (_, i) => `Zeile ${String(i).padStart(4, '0')} `.padEnd(breite, 'x')).join('\n') + +let zaehler = 0 +/** Projekt (Kunde c1) anlegen und die Funktion bestellen: liefert { projectId, sessionId }. */ +async function bestelle(key, projekt = {}) { + zaehler += 1 + const projectId = `bt${zaehler}` + const sessionId = `cs_bt${zaehler}` + aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Projekt ${zaehler}`, ...projekt }) + await sf.markOrdered({ customerId: 'c1', projectId, key, sessionId, consent: {} }) + return { projectId, sessionId } +} + +/** Register-Funktion tauschen und danach zurueckstellen. */ +async function mitTermineDetails(fn, run) { + const alt = config.features.termine.ticket.details + config.features.termine.ticket.details = fn + try { + return await run() + } finally { + config.features.termine.ticket.details = alt + } +} + +/** + * POSTs auf workorders mitschreiben ({ woid, topic, details, status }) und die ersten POSTs scheitern lassen: + * fail = Liste von { status, message } (der Nachbau liefert den Fehler) oder 'netz' (fetch wirft, kein Status). + */ +async function mitPosts(fail, run) { + const original = globalThis.fetch + const posts = [] + const queue = [...fail] + globalThis.fetch = async (input, init = {}) => { + const url = new URL(String(input?.url ?? input)) + if ((init.method || 'GET') === 'POST' && url.pathname.endsWith('/collections/workorders/documents')) { + const { data } = JSON.parse(init.body) + const eintrag = { woid: data.woid, topic: data.topic, details: data.details, status: null } + posts.push(eintrag) + const next = queue.shift() + if (next === 'netz') { + eintrag.status = 'netz' + throw new TypeError('fetch failed') + } + if (next) aw.failNext({ collection: 'workorders', method: 'POST', status: next.status, message: next.message }) + const res = await original(input, init) + eintrag.status = res.status + return res + } + return original(input, init) + } + try { + return await run(posts) + } finally { + globalThis.fetch = original + } +} + +// ----------------------------------------------------------------------------------- kuerze --- +test('kuerze: kurze Texte bleiben unveraendert, lange werden hart gekuerzt', () => { + assert.equal(reg.kuerze('abc', 5), 'abc') + assert.equal(reg.kuerze('abcde', 5, { endzeile: '…' }), 'abcde') + assert.equal(reg.kuerze('a'.repeat(10), 5), 'aaaaa') + assert.equal(reg.kuerze('abcdefghij', 6, { endzeile: '…' }), 'abcde…') + assert.equal(reg.kuerze(null, 5), '') +}) + +test('kuerze: nie ein halbes Zeichenpaar', () => { + const kurz = reg.kuerze('😀'.repeat(10), 5) + assert.equal(kurz, '😀😀') + assert.doesNotMatch(kurz, HALBES_PAAR) + const mitEnde = reg.kuerze('😀'.repeat(10), 6, { endzeile: '…' }) + assert.equal(mitEnde, '😀😀…') + assert.doesNotMatch(mitEnde, HALBES_PAAR) + for (let max = 1; max < 12; max += 1) { + const text = reg.kuerze('a😀b😀c😀d😀e', max, { endzeile: max > 3 ? '…' : '' }) + assert.ok(text.length <= max, `max ${max}: ${text.length}`) + assert.doesNotMatch(text, HALBES_PAAR, `max ${max}`) + } +}) + +test('kuerze: anZeile schneidet an der letzten Zeilengrenze und haengt die Endzeile in eine eigene Zeile', () => { + assert.equal(reg.kuerze('eins\nzwei\ndrei\nvier', 16, { anZeile: true, endzeile: 'ENDE' }), 'eins\nzwei\nENDE') + // die Zeile endet genau am Budget + assert.equal(reg.kuerze('aaaa\nbbbb\ncccc\ndddd', 14, { anZeile: true, endzeile: 'ENDE' }), 'aaaa\nbbbb\nENDE') + // keine Zeilengrenze im Budget: harter Schnitt (surrogatsicher) + const hart = reg.kuerze('😀'.repeat(30), 21, { anZeile: true, endzeile: 'ENDE' }) + assert.equal(hart, '😀'.repeat(8) + '\nENDE') + assert.ok(hart.length <= 21) + assert.doesNotMatch(hart, HALBES_PAAR) +}) + +// ----------------------------------------------------------------------------- anleitungsLink --- +test('anleitungsLink: WOMS-Kunde, Reiter funktionen, Projekt kodiert', () => { + assert.equal(reg.anleitungsLink('c1', 'p1'), `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p1`) + assert.equal(reg.anleitungsLink('c1', 'p 1/ä'), `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${encodeURIComponent('p 1/ä')}`) +}) + +// ------------------------------------------------------------------- minimaleTicketDetails --- +test('minimaleTicketDetails: Kopfzeilen und Verweis', () => { + const ctx = { + feature: reg.featureConfig('termine'), + doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' }, + project: { projectName: 'Salon', liveDomain: 'salon.de', previewUrl: 'https://p.example', subdomain: 'salon' }, + customer: { name: 'Frau Muster' }, + } + const link = `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p9` + assert.equal( + reg.minimaleTicketDetails(ctx), + ['Funktion: termine', 'Projekt: Salon', 'Domain: salon.de', 'Kunde: Frau Muster', 'siteKey: abcdefghijklmnop', `Verweis: ${link}`].join('\n') + ) + assert.match(reg.minimaleTicketDetails({ ...ctx, project: { subdomain: 's', previewUrl: 'https://p.example' } }), /^Projekt: s\nDomain: https:\/\/p\.example\n/m) + // ohne Kunde und ohne Domain bleibt die Zeile bestehen + const ohne = reg.minimaleTicketDetails({ ...ctx, customer: null, project: { projectName: 'X' } }) + assert.match(ohne, /^Domain: $/m) + assert.match(ohne, /^Kunde: c1$/m) +}) + +// ------------------------------------------------------------------------------ ticketTexte --- +test('ticketTexte: Register-Texte, Deckel und Rueckfall bei unbrauchbaren Details', async () => { + const feature = reg.featureConfig('termine') + const doc = { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' } + const ctx = { feature, doc, project: { projectName: 'Salon' }, customer: { name: 'Frau Muster' } } + const minimal = reg.minimaleTicketDetails(ctx) + + const normal = await mitTermineDetails(async () => 'Anleitung kurz', () => reg.ticketTexte(ctx)) + assert.deepEqual(normal, { topic: 'Terminplaner einbauen – Salon', details: 'Anleitung kurz', fallbackDetails: minimal }) + + for (const unbrauchbar of ['', ' \n ', null, undefined, 42, {}]) { + const r = await mitTermineDetails(async () => unbrauchbar, () => reg.ticketTexte(ctx)) + assert.equal(r.details, minimal, `Wert ${JSON.stringify(unbrauchbar)}`) + } + const wirft = await mitTermineDetails(async () => { throw new Error('kaputt') }, () => reg.ticketTexte(ctx)) + assert.equal(wirft.details, minimal) + + const lang = await mitTermineDetails(async () => zeilen(400), () => reg.ticketTexte(ctx)) + assert.ok(lang.details.length <= reg.TICKET_DETAILS_MAX) + assert.ok(lang.details.endsWith(`\n${GEKUERZT}`)) + + const langerName = await reg.ticketTexte({ ...ctx, project: { projectName: 'N'.repeat(255) } }) + assert.ok(langerName.topic.length <= reg.TICKET_TOPIC_MAX) + assert.ok(langerName.topic.startsWith('Terminplaner einbauen – NNN')) +}) + +test('Konstanten des Deckels', () => { + assert.equal(reg.TICKET_TOPIC_MAX, 128) + assert.equal(reg.TICKET_DETAILS_MAX, 7000) + assert.equal(reg.LANGE_DETAILS, 1000) +}) + +// ------------------------------------------------------------------------------ News byte-gleich --- +test('News-Ticket byte-gleich', async () => { + const { projectId, sessionId } = await bestelle('news', { projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) + const { doc, created } = await sf.markPaid({ sessionId }) + assert.equal(created, true) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.equal(ticket.topic, 'News-Pinnwand einbauen – Bäckerei') + assert.equal( + ticket.details, + [ + 'Funktion: news', + 'Projekt: Bäckerei', + 'Domain: baeckerei.de', + `siteKey: ${doc.siteKey}`, + `Einbau:
+ https://project.webklar.com/embed/news.js`, + 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', + ].join('\n') + ) + assert.equal(doc.projectId, projectId) +}) + +// ------------------------------------------------------------------------ Termine aus dem Register --- +test('Termine-Ticket aus dem Register', async () => { + const { projectId, sessionId } = await bestelle('termine', { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.de' }) + const { doc, created } = await sf.markPaid({ sessionId }) + assert.equal(created, true) + assert.equal(doc.state, 'setup') + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.ok(ticket.topic.startsWith('Terminplaner einbauen – ')) + assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie') + assert.ok(ticket.details.includes(doc.siteKey)) + assert.ok(ticket.details.includes(`?tab=funktionen&anleitung=${projectId}`)) + assert.ok(ticket.details.includes('Kunde: Kunde Eins')) + assert.ok(ticket.details.includes('Domain: haarlinie.de')) + assert.match(ticket.details, /Einrichtung bezahlt \(149 €\), Zeit nicht auf das Stundenkontingent anrechnen\.$/) + assert.equal(ticket.customerId, 'c1') +}) + +test('Stub termineTicketDetails: Minimal-Details plus Hinweis zum Stundenkontingent', async () => { + const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js') + const ctx = { + feature: reg.featureConfig('termine'), + doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' }, + project: { projectName: 'Salon' }, + customer: null, + } + assert.equal( + await termineTicketDetails(ctx), + `${reg.minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.` + ) +}) + +// ----------------------------------------------------------------------------------- Deckel --- +test('Deckel: Projektname mit 255 Zeichen', async () => { + const { sessionId } = await bestelle('termine', { projectName: 'P'.repeat(255) }) + const { doc } = await sf.markPaid({ sessionId }) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.ok(ticket.topic.length <= 128, `topic ${ticket.topic.length}`) + assert.ok(ticket.topic.startsWith('Terminplaner einbauen – PPP')) +}) + +test('Deckel: 100 Emojis als Projektname ergeben eine gueltige Zeichenkette', async () => { + const { sessionId } = await bestelle('termine', { projectName: '😀'.repeat(100) }) + const { doc } = await sf.markPaid({ sessionId }) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.ok(ticket.topic.length <= 128) + assert.doesNotMatch(ticket.topic, HALBES_PAAR) + assert.ok(ticket.topic.includes('😀')) + assert.doesNotMatch(ticket.details, HALBES_PAAR) + assert.equal(JSON.parse(JSON.stringify(ticket.topic)), ticket.topic) +}) + +test('Deckel: 20000 Zeichen Details werden an einer Zeilengrenze mit Schlusszeile gekuerzt', async () => { + const original = zeilen(400) + assert.ok(original.length > 19000) + const { sessionId } = await bestelle('termine') + const { doc } = await mitTermineDetails(async () => original, () => sf.markPaid({ sessionId })) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.ok(ticket.details.length <= 7000, `details ${ticket.details.length}`) + assert.ok(ticket.details.length > 6800) + assert.ok(ticket.details.endsWith(`\n${GEKUERZT}`)) + const rumpf = ticket.details.slice(0, -(GEKUERZT.length + 1)) + assert.ok(original.startsWith(`${rumpf}\n`), 'Schnitt an einer Zeilengrenze') + assert.equal(ticket.details.split(GEKUERZT).length, 2, 'genau eine Schlusszeile') +}) + +test('Deckel: genau 7000 Zeichen bleiben unveraendert', async () => { + const genau = zeilen(201, 34).slice(0, 7000) + assert.equal(genau.length, 7000) + const { sessionId } = await bestelle('termine') + const { doc } = await mitTermineDetails(async () => genau, () => sf.markPaid({ sessionId })) + assert.equal(aw.docs('workorders').get(doc.ticketId).details, genau) +}) + +// ----------------------------------------------------------------------------------- Notfall --- +test('Notfall: wirft die Details-Funktion, entsteht das Ticket mit den Minimal-Details', async () => { + const { projectId, sessionId } = await bestelle('termine', { projectName: 'Notfall', liveDomain: 'notfall.de' }) + const { doc, created } = await mitTermineDetails(async () => { + throw new Error('Anleitung nicht berechenbar') + }, () => sf.markPaid({ sessionId })) + assert.equal(created, true) + assert.equal(doc.state, 'setup') + assert.ok(doc.ticketId) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.equal( + ticket.details, + [ + 'Funktion: termine', + 'Projekt: Notfall', + 'Domain: notfall.de', + 'Kunde: Kunde Eins', + `siteKey: ${doc.siteKey}`, + `Verweis: ${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${projectId}`, + ].join('\n') + ) +}) + +test('Notfall: leere oder keine Zeichenkette gilt wie ein Fehler', async () => { + for (const unbrauchbar of [' ', null, 7]) { + const { sessionId } = await bestelle('termine') + const { doc } = await mitTermineDetails(async () => unbrauchbar, () => sf.markPaid({ sessionId })) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.match(ticket.details, /^Funktion: termine\n/) + assert.match(ticket.details, /\nKunde: Kunde Eins\n/) + assert.match(ticket.details, /\nVerweis: .*\?tab=funktionen&anleitung=bt\d+$/) + assert.doesNotMatch(ticket.details, /Einrichtung bezahlt/) + } +}) + +// ---------------------------------------------------------------- Wiederholung nur bei langen Details --- +test('Wiederholung: lange Details und ein 400 => zweiter Versuch mit den Minimal-Details, gleiche woid', async () => { + const { projectId, sessionId } = await bestelle('termine', { projectName: 'Lang' }) + const vorher = workorders().length + const lang = zeilen(100) // 5000 Zeichen + const { doc, created } = await mitTermineDetails(async () => lang, () => + mitPosts([{ status: 400, message: 'zu lang' }], async (posts) => { + const r = await sf.markPaid({ sessionId }) + assert.equal(posts.length, 2) + assert.equal(posts[0].status, 400) + assert.equal(posts[1].status, 201) + assert.equal(posts[0].woid, posts[1].woid) + assert.equal(posts[0].details, lang) + assert.equal(posts[1].details.split('\n').at(0), 'Funktion: termine') + return r + }) + ) + assert.equal(created, true) + assert.equal(workorders().length, vorher + 1, 'genau ein Dokument') + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.ok(ticket.details.includes(`anleitung=${projectId}`)) + assert.ok(ticket.details.includes(doc.siteKey)) + assert.doesNotMatch(ticket.details, /Zeile 0001/, 'ohne Snippet') + assert.doesNotMatch(ticket.details, /Einrichtung bezahlt/) +}) + +test('Wiederholung: zweimal 400 => der erste Fehler, Dokument bleibt setup, spaeter genau ein Ticket', async () => { + const { sessionId } = await bestelle('termine', { projectName: 'Doppelt' }) + const vorher = workorders().length + const lang = zeilen(100) + await mitTermineDetails(async () => lang, async () => { + await mitPosts([{ status: 400, message: 'erster Fehler' }, { status: 400, message: 'zweiter Fehler' }], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId }), (err) => err.status === 400 && err.message === 'erster Fehler') + assert.equal(posts.length, 2) + }) + assert.equal(workorders().length, vorher) + const stuck = (await sf.listCustomerFeatureDocs('c1')).find((d) => d.stripeSessionId === sessionId) + assert.equal(stuck.state, 'setup') + assert.ok(!stuck.ticketId) + // Stripe wiederholt: die Einrichtung wird fortgesetzt, genau ein Ticket + await mitPosts([], async (posts) => { + const { doc, created } = await sf.markPaid({ sessionId }) + assert.equal(created, true) + assert.ok(doc.ticketId) + assert.equal(posts.length, 1) + }) + assert.equal(workorders().length, vorher + 1) + assert.equal((await sf.markPaid({ sessionId })).created, false) + assert.equal(workorders().length, vorher + 1) + }) +}) + +test('Wiederholung: 503 bei langen Details wirft ohne Wiederholung (kein Doppel-Ticket)', async () => { + const { sessionId } = await bestelle('termine', { projectName: 'Fuenf Null Drei' }) + const vorher = workorders().length + await mitTermineDetails(async () => zeilen(100), () => + mitPosts([{ status: 503, message: 'nicht erreichbar' }], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId }), (err) => err.message === 'nicht erreichbar') + assert.equal(posts.length, 1) + }) + ) + assert.equal(workorders().length, vorher) +}) + +test('Wiederholung: Netzfehler bei langen Details wirft ohne Wiederholung', async () => { + const { sessionId } = await bestelle('termine', { projectName: 'Netz' }) + const vorher = workorders().length + await mitTermineDetails(async () => zeilen(100), () => + mitPosts(['netz'], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId }), TypeError) + assert.equal(posts.length, 1) + }) + ) + assert.equal(workorders().length, vorher) +}) + +test('Wiederholung: kurze Details (News, Standardtext) und ein 400 werfen ohne Wiederholung', async () => { + const vorher = workorders().length + const news = await bestelle('news', { projectName: 'Kurz News' }) + await mitPosts([{ status: 400, message: 'news kaputt' }], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId: news.sessionId }), (err) => err.status === 400 && err.message === 'news kaputt') + assert.equal(posts.length, 1) + }) + const termine = await bestelle('termine', { projectName: 'Kurz Termine' }) + await mitTermineDetails(async () => 'nur eine kurze Zeile', () => + mitPosts([{ status: 400, message: 'termine kaputt' }], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId: termine.sessionId }), (err) => err.message === 'termine kaputt') + assert.equal(posts.length, 1) + }) + ) + assert.equal(workorders().length, vorher) + // genau 1000 Zeichen zaehlen noch als kurz + const grenze = await bestelle('termine', { projectName: 'Grenze' }) + await mitTermineDetails(async () => 'g'.repeat(1000), () => + mitPosts([{ status: 400, message: 'grenze' }], async (posts) => { + await assert.rejects(sf.markPaid({ sessionId: grenze.sessionId }), (err) => err.message === 'grenze') + assert.equal(posts.length, 1) + }) + ) +}) + +// ------------------------------------------------------------------ Termine ueber den Webhook --- +test('Termine ueber markPaid: lange Kurzfassung + 400 => Ticket mit Minimal-Details, K15/T16 gehen raus', async () => { + const { projectId, sessionId } = await bestelle('termine', { projectName: 'Webhook Salon' }) + const vorherTickets = workorders().length + const vorherMails = mails.sent.length + const event = { + id: 'evt_b8', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + id: sessionId, + object: 'checkout.session', + mode: 'payment', + payment_status: 'paid', + metadata: { customerId: 'c1', projectId, featureKey: 'termine' }, + }, + }, + } + await mitTermineDetails(async () => zeilen(100), () => + mitPosts([{ status: 400, message: 'zu lang' }], async (posts) => { + await sf.handleFeatureCheckoutEvent(event) + assert.equal(posts.length, 2) + }) + ) + assert.equal(workorders().length, vorherTickets + 1) + const doc = await sf.getFeatureDoc('c1', projectId, 'termine') + assert.equal(doc.state, 'setup') + assert.ok(doc.ticketId) + assert.match(aw.docs('workorders').get(doc.ticketId).details, /^Funktion: termine\n/) + assert.equal(mails.sent.length - vorherMails, 2, 'K15 an den Kunden und T16 an das Team') + // Stripe wiederholt: nichts doppelt + await sf.handleFeatureCheckoutEvent(event) + assert.equal(workorders().length, vorherTickets + 1) + assert.equal(mails.sent.length - vorherMails, 2) +}) + +// ---------------------------------------------------------------------------- Analyse unberuehrt --- +test('Analyse unberuehrt: eigener Text ohne fallbackDetails, ein Aufruf, Deckel nur bei Ueberlaenge', async () => { + const project = { $id: 'pa_analyse', projectName: 'Analyse Projekt', subdomain: 'ap' } + await mitPosts([], async (posts) => { + const { ticketId } = await createFeatureTicket({ + customerId: 'c1', + project, + featureKey: 'analyse', + siteKey: 'abcdefghijklmnop', + topic: 'Analyse einbauen – Analyse Projekt', + details: 'Zeile eins\nZeile zwei', + }) + assert.equal(posts.length, 1) + const ticket = aw.docs('workorders').get(ticketId) + assert.equal(ticket.topic, 'Analyse einbauen – Analyse Projekt') + assert.equal(ticket.details, 'Zeile eins\nZeile zwei') + }) + // Ueberlaenge wird gedeckelt + const { ticketId } = await createFeatureTicket({ + customerId: 'c1', + project, + featureKey: 'analyse', + siteKey: 'abcdefghijklmnop', + topic: 'T'.repeat(200), + details: zeilen(400), + }) + const lang = aw.docs('workorders').get(ticketId) + assert.ok(lang.topic.length <= 128) + assert.ok(lang.details.length <= 7000) + assert.ok(lang.details.endsWith(`\n${GEKUERZT}`)) + // lange Details, 400 und kein fallbackDetails: keine Wiederholung + await mitPosts([{ status: 400, message: 'abgelehnt' }], async (posts) => { + await assert.rejects( + createFeatureTicket({ customerId: 'c1', project, featureKey: 'analyse', siteKey: 'abcdefghijklmnop', topic: 'Analyse', details: zeilen(100) }), + (err) => err.message === 'abgelehnt' + ) + assert.equal(posts.length, 1) + }) +}) + +test('Defaults: ohne topic und details der heutige News-Text', async () => { + const project = { $id: 'pa_def', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' } + const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: 'abcdefghijklmnop' }) + const ticket = aw.docs('workorders').get(ticketId) + assert.equal(ticket.topic, 'News-Pinnwand einbauen – Bäckerei') + assert.equal( + ticket.details, + [ + 'Funktion: news', + 'Projekt: Bäckerei', + 'Domain: baeckerei.de', + 'siteKey: abcdefghijklmnop', + 'Einbau:
+ https://project.webklar.com/embed/news.js', + 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', + ].join('\n') + ) + // nur topic gesetzt: Details bleiben der Standard + const { ticketId: zweite } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: 'abcdefghijklmnop', topic: 'Mein Thema' }) + assert.equal(aw.docs('workorders').get(zweite).topic, 'Mein Thema') + assert.equal(aw.docs('workorders').get(zweite).details, ticket.details) +})