From 35c980085684dd0d3276780050ea67a951a04f20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:58:06 +0000 Subject: [PATCH] Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js) featureView liefert zusaetzlich disabledReason, disabledAt und loeschungAm (Berliner Tag, 30 Tage nach disabledAt); alle drei nur im Zustand disabled, sonst null. Neu termine/frist.js mit LOESCHFRIST_TAGE, loeschzeit und loeschTag (nur zeit.js/util.js). Co-Authored-By: Claude Sonnet 5.5 --- server/services/siteFeatures.js | 11 +++ server/services/termine/frist.js | 29 ++++++++ test/funktionenTermine.test.js | 30 ++++++++ test/siteFeatures.test.js | 113 +++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 server/services/termine/frist.js diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 028e814..b546f64 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -7,6 +7,7 @@ import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' import { laeuft } from './aboStatus.js' +import { loeschTag } from './termine/frist.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = featureKeys() @@ -119,7 +120,14 @@ async function ticketWoid(ticketId) { } } +/** + * Ansicht einer Funktion fuer Kunde und Team. Abschaltgrund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt + * (disabledAt) und Berliner Loeschtag (loeschungAm, 30 Tage danach, termine/frist.js) gibt es nur im Zustand `disabled`; + * in jedem anderen Zustand sind alle drei null, auch wenn am Dokument noch Reste einer frueheren Abschaltung stehen. + * loeschungAm ist null, wenn disabledAt fehlt oder nicht lesbar ist. + */ async function featureView(doc) { + const abgeschaltet = doc?.state === 'disabled' return { state: doc?.state || 'off', siteKey: doc?.siteKey || null, @@ -127,6 +135,9 @@ async function featureView(doc) { orderedAt: doc?.orderedAt || null, activatedAt: doc?.activatedAt || null, invoiceUrl: doc?.invoiceUrl || null, + disabledReason: abgeschaltet ? doc.disabledReason || null : null, + disabledAt: abgeschaltet ? doc.disabledAt || null : null, + loeschungAm: abgeschaltet ? loeschTag(doc.disabledAt) : null, } } diff --git a/server/services/termine/frist.js b/server/services/termine/frist.js new file mode 100644 index 0000000..dddb5d1 --- /dev/null +++ b/server/services/termine/frist.js @@ -0,0 +1,29 @@ +/** + * Loeschfrist abgeschalteter Terminplaner: 30 Tage nach `disabledAt` loescht der Tageslauf (jobs.js) die Buchungen und die + * Einstellung. Diese Datei haelt die Frist an einer Stelle, damit Anzeige (siteFeatures.featureView), Mails und Rechtstexte + * dieselbe Zahl und dieselbe Rechnung benutzen. Reine Funktionen, nur Importe aus zeit.js und util.js (kein Kreis mit siteFeatures.js). + */ +import { ausUtc } from './zeit.js' +import { zeitMs } from './util.js' + +/** Tage zwischen dem Abschalten (disabledAt) und der Loeschung. */ +export const LOESCHFRIST_TAGE = 30 + +const TAG_MS = 24 * 60 * 60 * 1000 + +/** + * Zeitpunkt, ab dem die Daten geloescht werden duerfen: disabledAt plus 30 x 24 Stunden (keine Wanduhr-Rechnung, wie der Tageslauf). + * disabledAt: ISO-String (auch mit +00:00 wie von Appwrite), Date oder Zahl; fehlend oder ungueltig => null. + */ +export function loeschzeit(disabledAt) { + const ms = zeitMs(disabledAt) + if (!Number.isFinite(ms)) return null + const ende = new Date(ms + LOESCHFRIST_TAGE * TAG_MS) + return Number.isNaN(ende.getTime()) ? null : ende +} + +/** Berliner Kalendertag der Loeschung ('JJJJ-MM-TT') oder null, wenn disabledAt fehlt oder ungueltig ist. */ +export function loeschTag(disabledAt) { + const ende = loeschzeit(disabledAt) + return ende ? ausUtc(ende).tag : null +} diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 1674d14..b9c177a 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -18,6 +18,7 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const sf = await import('../server/services/siteFeatures.js') +const { loeschTag } = await import('../server/services/termine/frist.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) @@ -173,10 +174,39 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher' const pt2 = body.projects.find((p) => p.projectId === 'pt2') assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off']) assert.deepEqual(Object.keys(pt2.features), ['news', 'termine']) + // Abschaltgrund und Loeschfrist (E2): News ist hier vom Team abgeschaltet, der aktive Terminplaner und das leere Projekt haben nichts + const aus = pt1.features.news + assert.equal(aus.disabledReason, 'team') + assert.ok(Math.abs(Date.parse(aus.disabledAt) - Date.now()) < 60000) + assert.equal(aus.loeschungAm, loeschTag(aus.disabledAt)) + for (const view of [pt1.features.termine, pt2.features.news, pt2.features.termine]) { + assert.deepEqual([view.disabledReason, view.disabledAt, view.loeschungAm], [null, null, null]) + } // ohne Sitzung wie bisher gesperrt assert.equal((await rawGet('/api/funktionen')).status, 401) }) +test('GET /api/funktionen: abgeschalteter Terminplaner nennt Grund, Zeitpunkt und Berliner Loeschtag; Altbestand ohne Grund', async () => { + const projekte = [ + ['pe2_aboende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }], + ['pe2_team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }], + ['pe2_alt', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }], + ['pe2_aktiv', { state: 'active', enabled: true }], + ] + for (const [id, felder] of projekte) { + aw.seed('websiteProjects', { $id: id, customerId: 'ce2', projectName: id }) + aw.seed('portalFeatures', { $id: `f_${id}_termine`, customerId: 'ce2', projectId: id, featureKey: 'termine', siteKey: 'abcdefghijklmnop', ...felder }) + } + const body = await get('/api/funktionen', { cookie: customerCookie('ce2') }) + const termine = (id) => body.projects.find((p) => p.projectId === id).features.termine + const felder = (id) => ({ disabledReason: termine(id).disabledReason, disabledAt: termine(id).disabledAt, loeschungAm: termine(id).loeschungAm }) + assert.deepEqual(felder('pe2_aboende'), { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }, 'Berliner Tag, nicht der UTC-Tag 2026-11-03') + assert.deepEqual(felder('pe2_team'), { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }) + assert.deepEqual(felder('pe2_alt'), { disabledReason: null, disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }, 'Altbestand ohne Grund') + assert.deepEqual(felder('pe2_aktiv'), { disabledReason: null, disabledAt: null, loeschungAm: null }) + assert.equal(termine('pe2_aboende').state, 'disabled') +}) + // --------------------------------------------------------- Kunde loeschen --- test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => { aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' }) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index cc666f1..c27c133 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -11,6 +11,7 @@ const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const sf = await import('../server/services/siteFeatures.js') +const { LOESCHFRIST_TAGE, loeschzeit, loeschTag } = await import('../server/services/termine/frist.js') aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' }) aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei' }) @@ -607,3 +608,115 @@ test('markPaid: gleichzeitige Aufrufe buchen die Zahlung einmal und liefern alle assert.equal(lesen(id).adminNote.split('System: Zahlung eingegangen').length - 1, 1, 'ein Eintrag in der Notiz') assert.equal(lesen(id).zahlungMeldung, 'abo_ende') }) + +// ------------------------------------------------ E2: Abschaltgrund und Loeschfrist in der Ansicht --- +const TAG_MS = 24 * 60 * 60 * 1000 + +test('frist: LOESCHFRIST_TAGE sind 30, loeschzeit ist disabledAt plus genau 30 x 24 h', () => { + assert.equal(LOESCHFRIST_TAGE, 30) + const bis = loeschzeit('2026-10-04T23:30:00.000Z') + assert.ok(bis instanceof Date) + assert.equal(bis.toISOString(), '2026-11-03T23:30:00.000Z') + assert.equal(bis.getTime() - Date.parse('2026-10-04T23:30:00.000Z'), 30 * TAG_MS) + // 24-Stunden-Rechnung, keine Wanduhr: ueber die Zeitumstellung am 25.10. hinweg bleiben es 720 Stunden + const ueberUmstellung = loeschzeit('2026-10-10T12:00:00.000Z') + assert.equal(ueberUmstellung.toISOString(), '2026-11-09T12:00:00.000Z') + assert.equal(ueberUmstellung.getTime() - Date.parse('2026-10-10T12:00:00.000Z'), 720 * 60 * 60 * 1000) + // Appwrite liefert +00:00; ein Date wird nicht veraendert + assert.equal(loeschzeit('2026-10-04T23:30:00.000+00:00').toISOString(), '2026-11-03T23:30:00.000Z') + const eingabe = new Date('2026-10-04T23:30:00.000Z') + assert.equal(loeschzeit(eingabe).toISOString(), '2026-11-03T23:30:00.000Z') + assert.equal(eingabe.toISOString(), '2026-10-04T23:30:00.000Z', 'die Eingabe bleibt unveraendert') +}) + +test('frist: loeschTag ist der Berliner Kalendertag, nicht der UTC-Tag', () => { + assert.equal(loeschTag('2026-10-04T23:30:00.000Z'), '2026-11-04', 'Berlin 00:30 am 04.11., UTC waere der 03.11.') + assert.equal(loeschTag('2026-10-04T10:00:00.000Z'), '2026-11-03') + // Sommerzeit: 31.07. 22:30 UTC + 30 Tage = 30.08. 22:30 UTC = 31.08. 00:30 Berlin + assert.equal(loeschTag('2026-07-31T22:30:00.000Z'), '2026-08-31') + assert.equal(loeschTag(new Date('2026-10-04T23:30:00.000Z')), '2026-11-04') +}) + +test('frist: ungueltig, leer oder fehlend ergibt null (kein Wurf)', () => { + for (const wert of [undefined, null, '', 'kein Datum', '2026-13-45T00:00:00Z', new Date('x'), NaN, Infinity, {}, []]) { + assert.equal(loeschzeit(wert), null, `loeschzeit(${String(wert)})`) + assert.equal(loeschTag(wert), null, `loeschTag(${String(wert)})`) + } +}) + +const E2_ZUSTAENDE = [ + // [Name, Dokumentfelder, erwartet { disabledReason, disabledAt, loeschungAm }] + ['disabled abo_ende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }, + { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }], + ['disabled team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }, + { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }], + ['disabled abo_ende_vor_einrichtung', { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-02T08:00:00.000Z' }, + { disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-02T08:00:00.000Z', loeschungAm: '2026-11-01' }], + ['disabled kunde_geloescht', { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: '2026-10-03T08:00:00.000Z' }, + { disabledReason: 'kunde_geloescht', disabledAt: '2026-10-03T08:00:00.000Z', loeschungAm: '2026-11-02' }], + ['disabled Altbestand ohne Grund', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }, + { disabledReason: null, disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }], + ['disabled ohne disabledAt (Altbestand)', { state: 'disabled', enabled: false, disabledReason: 'team' }, + { disabledReason: 'team', disabledAt: null, loeschungAm: null }], + ['disabled mit leerem disabledAt', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '' }, + { disabledReason: 'team', disabledAt: null, loeschungAm: null }], + ['disabled mit unlesbarem disabledAt', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: 'kein Datum' }, + { disabledReason: 'team', disabledAt: 'kein Datum', loeschungAm: null }], + // Reste aus frueheren Abschaltungen zaehlen in anderen Zustaenden nie + ['active mit Resten', { state: 'active', enabled: true, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }, + { disabledReason: null, disabledAt: null, loeschungAm: null }], + ['setup mit Resten', { state: 'setup', enabled: false, disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }, + { disabledReason: null, disabledAt: null, loeschungAm: null }], + ['ordered mit Resten', { state: 'ordered', enabled: false, disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }, + { disabledReason: null, disabledAt: null, loeschungAm: null }], + ['off mit Resten', { state: 'off', enabled: false, disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }, + { disabledReason: null, disabledAt: null, loeschungAm: null }], + ['active ohne Reste', { state: 'active', enabled: true }, { disabledReason: null, disabledAt: null, loeschungAm: null }], + ['active nach activate() (null-Felder)', { state: 'active', enabled: true, disabledReason: null, disabledAt: null }, + { disabledReason: null, disabledAt: null, loeschungAm: null }], +] + +test('featureView: Abschaltgrund, Zeitpunkt und Loeschtag nur im Zustand disabled, sonst alle drei null', async () => { + aw.seed('customers', { $id: 'c_e2', name: 'Kunde E2' }) + E2_ZUSTAENDE.forEach(([name, felder], i) => { + aw.seed('websiteProjects', { $id: `p_e2_${i}`, customerId: 'c_e2', projectName: `E2 ${name}` }) + aw.seed('portalFeatures', { $id: `f_e2_${i}`, customerId: 'c_e2', projectId: `p_e2_${i}`, featureKey: 'termine', siteKey: 'abcdefghijklmnop', ...felder }) + }) + aw.seed('websiteProjects', { $id: 'p_e2_ohne', customerId: 'c_e2', projectName: 'E2 ohne Dokument' }) + const liste = await sf.listCustomerFeatures('c_e2') + assert.equal(liste.length, E2_ZUSTAENDE.length + 1) + E2_ZUSTAENDE.forEach(([name, , erwartet], i) => { + const view = liste.find((p) => p.projectId === `p_e2_${i}`).features.termine + assert.deepEqual( + { disabledReason: view.disabledReason, disabledAt: view.disabledAt, loeschungAm: view.loeschungAm }, + erwartet, + name + ) + }) + // kein Dokument = off: alle drei Felder sind da und null; die Felder der anderen Funktion bleiben davon unberuehrt + const ohne = liste.find((p) => p.projectId === 'p_e2_ohne').features + for (const key of Object.keys(ohne)) { + assert.equal(ohne[key].state, 'off', key) + assert.deepEqual([ohne[key].disabledReason, ohne[key].disabledAt, ohne[key].loeschungAm], [null, null, null], key) + } + const erster = liste.find((p) => p.projectId === 'p_e2_0').features + assert.equal(erster.termine.state, 'disabled') + assert.equal(erster.news.state, 'off') + assert.equal(erster.news.loeschungAm, null) +}) + +test('featureView: nach disable() steht der Grund in der Ansicht und die Frist ist 30 Tage nach disabledAt', async () => { + aw.seed('websiteProjects', { $id: 'p_e2_live', customerId: 'c_e2', projectName: 'E2 live' }) + aw.seed('portalFeatures', { $id: 'f_e2_live', customerId: 'c_e2', projectId: 'p_e2_live', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) + const vorher = (await sf.listCustomerFeatures('c_e2')).find((p) => p.projectId === 'p_e2_live').features.termine + assert.deepEqual([vorher.disabledReason, vorher.disabledAt, vorher.loeschungAm], [null, null, null]) + const aus = await sf.disable('f_e2_live', { reason: 'abo_ende' }) + const view = (await sf.listCustomerFeatures('c_e2')).find((p) => p.projectId === 'p_e2_live').features.termine + assert.deepEqual([view.state, view.disabledReason, view.disabledAt], ['disabled', 'abo_ende', aus.disabledAt]) + assert.equal(view.loeschungAm, loeschTag(aus.disabledAt)) + assert.match(view.loeschungAm, /^\d{4}-\d\d-\d\d$/) + const wieder = await sf.activate('f_e2_live', {}) + assert.equal(wieder.state, 'active') + const danach = (await sf.listCustomerFeatures('c_e2')).find((p) => p.projectId === 'p_e2_live').features.termine + assert.deepEqual([danach.disabledReason, danach.disabledAt, danach.loeschungAm], [null, null, null], 'Wiedereinschalten leert die Anzeige') +})