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 <noreply@anthropic.com>
This commit is contained in:
1 parent
144b053851
commit
35c9800856
4 files changed
+183
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user