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) +})