diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index b359544..932e21b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -9,7 +9,7 @@ import { speichereZustimmung, zustimmungFuerSitzung } from './featureConsents.js import { aboLage, laeuft } from './aboStatus.js' import { berlinDateTime } from './berlinTime.js' import { loeschTag } from './termine/frist.js' -import { createFeatureTicket } from './ticketAdmin.js' +import { ABRECHNUNGS_ZEILEN, createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = featureKeys() /** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */ @@ -144,8 +144,8 @@ export const unlockedByVon = (doc) => doc?.unlockedBy || (doc?.unlockedByPurchas * bei News verspraeche das Datum eine Loeschung, die nie kommt) und fuer Oeffnungszeiten live, dort nur beim Abschaltgrund * abo_ende (bei einer Abschaltung durch das Team gibt es keine Loeschfrist); es ist null, wenn disabledAt fehlt oder nicht lesbar ist. * disabledReason und disabledAt gelten fuer jede Funktion. - * Funktionen mit aboInklusive (Oeffnungszeiten live) tragen zusaetzlich unlockedBy (kauf | abo | team, null solange die Funktion - * aus ist), aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus). + * Jede Funktion traegt unlockedBy (kauf | abo | projekt | team, null solange die Funktion aus ist). Funktionen mit aboInklusive + * (Oeffnungszeiten live) tragen zusaetzlich aboEndeAt (Ende des Wartungs-Abos erkannt) und einbau (angestossen | manuell, Ergebnis des Neubaus). * key: Funktionsschluessel (auch ohne Dokument); ohne Angabe der des Dokuments. */ export async function featureView(doc, key = doc?.featureKey) { @@ -162,9 +162,10 @@ export async function featureView(doc, key = doc?.featureKey) { disabledReason: abgeschaltet ? doc.disabledReason || null : null, disabledAt: abgeschaltet ? doc.disabledAt || null : null, loeschungAm: loeschtag ? loeschTag(doc.disabledAt) : null, + // unlockedBy (kauf | abo | projekt | team, null solange die Funktion aus ist) gilt fuer jede Funktion (V7); aboEndeAt und einbau nur mit aboInklusive + unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null, ...(mitAbo ? { - unlockedBy: doc && doc.state !== 'off' ? unlockedByVon(doc) : null, aboEndeAt: doc?.aboEndeAt || null, einbau: doc?.einbau || null, } @@ -454,6 +455,8 @@ async function legeTicketAn(doc, project, customer) { featureKey: doc.featureKey, siteKey: doc.siteKey, ...texte, + // Abo-Modell: die Abrechnung am Funktionsdokument (abo | projekt) kommt auch bei einem ersetzten Ticket wieder ans Ticket + ...(Object.hasOwn(ABRECHNUNGS_ZEILEN, doc.einrichtungAbrechnung) ? { abrechnung: doc.einrichtungAbrechnung } : {}), }) return ticketId } diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js index 7113298..11cd732 100644 --- a/server/services/ticketAdmin.js +++ b/server/services/ticketAdmin.js @@ -69,6 +69,15 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) { return ticket.$id } +/** + * Schlusszeile der Ticket-Details im Abo-Modell (V7): sagt dem Team, wie die Einrichtung abgerechnet wird. Steht immer als letzte + * Zeile, auch nach dem Kuerzen und im Notfallweg. + */ +export const ABRECHNUNGS_ZEILEN = { + abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).', + projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).', +} + /** * 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. @@ -78,6 +87,10 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) { * (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. + * abrechnung ('' | 'abo' | 'projekt', Abo-Modell): setzt am Ticket einrichtungFuer (= featureKey) und einrichtungAbrechnung und haengt + * ABRECHNUNGS_ZEILEN[abrechnung] als letzte Zeile an die Details (der Text wird dafuer auf TICKET_DETAILS_MAX minus Zeile gekuerzt). + * Ohne abrechnung schreibt das Ticket weder die Felder noch die Zeile (alte Ansicht unveraendert). Ein anderer Wert ist ein + * TypeError vor jedem Schreibzugriff. */ export async function createFeatureTicket({ customerId, @@ -87,14 +100,24 @@ export async function createFeatureTicket({ topic = null, details = null, fallbackDetails = null, + abrechnung = '', }) { + if (abrechnung !== '' && !(typeof abrechnung === 'string' && Object.hasOwn(ABRECHNUNGS_ZEILEN, abrechnung))) { + throw new TypeError(`createFeatureTicket: abrechnung muss '', 'abo' oder 'projekt' sein, nicht ${String(abrechnung)}`) + } + const zeile = abrechnung ? ABRECHNUNGS_ZEILEN[abrechnung] : '' + // Details auf TICKET_DETAILS_MAX kuerzen; mit Abrechnung bleibt Platz fuer die Schlusszeile (und ihren Zeilenumbruch) + const kuerzeDetails = (roh) => { + const gekuerzt = kuerze(roh, zeile ? TICKET_DETAILS_MAX - zeile.length - 1 : TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) + return { gekuerzt, text: zeile ? `${gekuerzt}\n${zeile}` : gekuerzt } + } 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 text = kuerze( + const { gekuerzt, text } = kuerzeDetails( details || [ `Funktion: ${featureKey}`, @@ -103,9 +126,7 @@ export async function createFeatureTicket({ `siteKey: ${siteKey}`, `Einbau: ${embed}`, 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', - ].join('\n'), - TICKET_DETAILS_MAX, - { anZeile: true, endzeile: TICKET_GEKUERZT } + ].join('\n') ) const fields = { topic: kuerze(topic || `News-Pinnwand einbauen – ${projectName}`.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }), @@ -119,6 +140,7 @@ export async function createFeatureTicket({ customerLocation: customer?.location || '', woid, createdAt: new Date().toISOString(), + ...(abrechnung ? { einrichtungFuer: featureKey, einrichtungAbrechnung: abrechnung } : {}), } let ticket @@ -126,12 +148,12 @@ export async function createFeatureTicket({ 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 + if (firstError?.status !== 400 || !hatRueckfall || gekuerzt.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 }) }, + { ...fields, details: kuerzeDetails(fallbackDetails).text }, ID.unique() ) } catch (secondError) { diff --git a/test/einrichtungsTicket.test.js b/test/einrichtungsTicket.test.js new file mode 100644 index 0000000..7298661 --- /dev/null +++ b/test/einrichtungsTicket.test.js @@ -0,0 +1,88 @@ +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' + +// Einrichtungs-Ticket mit Abrechnung (V7) und unlockedBy in featureView fuer alle Funktionen. +// workorders, websiteProjects 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 { createFeatureTicket, ABRECHNUNGS_ZEILEN } = await import('../server/services/ticketAdmin.js') +const { featureView, ticketNachlegen } = await import('../server/services/siteFeatures.js') + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' }) + +const ALT = ['createdAt', 'customerId', 'customerLocation', 'customerName', 'details', 'priority', 'serviceType', 'status', 'systemType', 'topic', 'type', 'woid'] +const base = { customerId: 'c1', project: { $id: 'p1', projectName: 'Bäckerei' }, featureKey: 'termine', siteKey: 'abcdefghijklmnop', topic: 'T', details: 'Zeile 1' } +const nutzer = (doc) => Object.keys(doc).filter((k) => !k.startsWith('$')).sort() +// aw.docs() liefert eine Kopie des Standes zum Aufruf: das Ticket erst anlegen, dann nachsehen +const ticketVon = async (args) => { + const { ticketId } = await createFeatureTicket(args) + return aw.docs('workorders').get(ticketId) +} + +test('abrechnung abo und projekt: Felder und letzte Zeile', async () => { + assert.deepEqual(ABRECHNUNGS_ZEILEN, { + abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).', + projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).', + }) + const t = await ticketVon({ ...base, abrechnung: 'abo' }) + assert.deepEqual([t.einrichtungFuer, t.einrichtungAbrechnung], ['termine', 'abo']) + assert.equal(t.details, `Zeile 1\n${ABRECHNUNGS_ZEILEN.abo}`) + assert.ok((await ticketVon({ ...base, abrechnung: 'projekt' })).details.endsWith('\nZeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).')) +}) + +test('ohne abrechnung: keine neuen Felder, Details unverändert (alte Ansicht)', async () => { + for (const abrechnung of [undefined, '']) { + const t = await ticketVon({ ...base, abrechnung }) + assert.deepEqual(nutzer(t), ALT) + assert.equal(t.details, 'Zeile 1') + } +}) + +test('ungültiger Wert: TypeError, kein Ticket', async () => { + const n = aw.docs('workorders').size + for (const abrechnung of ['frei', 'Abo', null, 5]) await assert.rejects(() => createFeatureTicket({ ...base, abrechnung }), TypeError, String(abrechnung)) + assert.equal(aw.docs('workorders').size, n) +}) + +test('lange Details: gekürzt auf 7000, die Schlusszeile bleibt; auch im Notfallweg nach 400', async () => { + const lang = Array.from({ length: 400 }, (_, i) => `Zeile ${i} ${'x'.repeat(30)}`).join('\n') + const t = await ticketVon({ ...base, details: lang, abrechnung: 'abo' }) + assert.ok(t.details.length <= 7000 && t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)) + assert.ok(t.details.length > 6800, 'es wird nicht zu stark gekürzt') + aw.failNext({ collection: 'workorders', method: 'POST', status: 400, message: 'zu lang' }) + const n = await ticketVon({ ...base, details: lang, fallbackDetails: 'Notfall', abrechnung: 'projekt' }) + assert.ok(n.details.startsWith('Notfall') && n.details.endsWith(ABRECHNUNGS_ZEILEN.projekt)) +}) + +test('featureView: unlockedBy für alle Funktionen, aboEndeAt/einbau nur bei Zeiten', async () => { + assert.equal((await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news')).unlockedBy, 'abo') + assert.equal((await featureView({ state: 'setup', unlockedBy: 'projekt' }, 'shop')).unlockedBy, 'projekt') + assert.equal((await featureView({ state: 'active', unlockedByPurchase: true }, 'termine')).unlockedBy, 'kauf') + assert.equal((await featureView(undefined, 'news')).unlockedBy, null) + assert.equal((await featureView({ state: 'off', unlockedBy: 'abo' }, 'news')).unlockedBy, null) + assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'einbau'), false) + assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'aboEndeAt'), false) + const zeiten = await featureView({ state: 'setup', unlockedBy: 'abo', einbau: 'angestossen' }, 'zeiten') + assert.deepEqual([zeiten.unlockedBy, zeiten.einbau, Object.hasOwn(zeiten, 'aboEndeAt')], ['abo', 'angestossen', true]) +}) + +test('ersetztes Ticket trägt die Abrechnung des Dokuments', async () => { + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' }) + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', einrichtungAbrechnung: 'abo', ticketId: 'weg' }) + const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'Kenso' }) + const ticket = aw.docs('workorders').get(doc.ticketId) + assert.deepEqual([ticket.einrichtungAbrechnung, ticket.einrichtungFuer], ['abo', 'news']) + assert.ok(ticket.details.endsWith(ABRECHNUNGS_ZEILEN.abo)) +}) + +test('ersetztes Ticket ohne Abrechnung am Dokument: wie bisher, keine neuen Felder', async () => { + aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', siteKey: 'bbcdefghijklmnop', state: 'setup', enabled: false, unlockedByPurchase: true, ticketId: 'weg2' }) + const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'termine', by: 'Kenso' }) + assert.deepEqual(nutzer(aw.docs('workorders').get(doc.ticketId)), ALT) +}) diff --git a/test/zeitenEinschalten.test.js b/test/zeitenEinschalten.test.js index 8f0a7ba..709adf0 100644 --- a/test/zeitenEinschalten.test.js +++ b/test/zeitenEinschalten.test.js @@ -613,9 +613,10 @@ test('Ansicht features.zeiten: unlockedBy, aboEndeAt, einbau; loeschungAm nur be const team = await ansicht({ state: 'disabled', unlockedBy: 'team', disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }) assert.equal(team.loeschungAm, null) - // andere Funktionen behalten ihre Ansicht + // andere Funktionen behalten ihre Ansicht; einzige Erweiterung (V7, Kundenansicht neu): unlockedBy gilt fuer jede Funktion (null solange sie aus ist) const news = (await liste()).body.projects[0].features.news - assert.equal('einbau' in news || 'unlockedBy' in news || 'aboEndeAt' in news, false) + assert.equal('einbau' in news || 'aboEndeAt' in news, false) + assert.equal(news.unlockedBy, null) }) // ----------------------------------------------------------------------------------------------------- Projektliste