Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup

disable() laeuft unter einer Sperre je Dokument und aendert ein schon
abgeschaltetes Dokument nicht mehr (disabledAt als Beginn der 30-Tage-Frist,
Grund und Notiz bleiben); nur Altbestand ohne disabledAt bekommt dieses Feld.
Neu: DISABLED_REASONS und der Parameter reason (Vorgabe team, unbekannt = 400),
disableCustomerFeatures reicht ihn durch, die Kundenloeschung nutzt
kunde_geloescht. activate() leert zusaetzlich disabledReason und aboEndeMailAt
und laesst zahlungMeldung unberuehrt.

scripts/feature-setup.mjs legt portalFeatures.disabledReason, aboEndeMailAt und
zahlungMeldung idempotent an (--check meldet Luecken mit Exit 1);
schemaKeys('portalFeatures') zieht die Schluessel mit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:33:17 +00:00
1 parent 39225594e5
commit aaed273b62
9 files changed
+472 -18

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der
* Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung).
* Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die neuen
* Felder; ohne Schema antwortet Appwrite mit "Unknown attribute").
* scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus).
* Usage:
* node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken)
* node scripts/feature-setup.mjs (Fehlendes anlegen)
*
* config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die
* Attributliste, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen.
*/
import { pathToFileURL } from 'node:url'
/**
* Neue Attribute von portalFeatures (alle optional):
* - disabledReason: 'team' | 'abo_ende' | 'abo_ende_vor_einrichtung' | 'kunde_geloescht' (laengster Wert 24 Zeichen)
* - aboEndeMailAt: Zeitpunkt der versandten Mail "Terminplaner beendet" (Marker, nach dem Versand gesetzt)
* - zahlungMeldung: '' | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' | 'gemeldet' (Marker einer Zahlung ohne Ticket)
*/
export const PORTAL_FEATURE_ATTRIBUTES = [
{ type: 'string', key: 'disabledReason', size: 32, required: false },
{ type: 'datetime', key: 'aboEndeMailAt', required: false },
{ type: 'string', key: 'zahlungMeldung', size: 24, required: false },
]
export async function main({ check = false, client, log = console.log } = {}) {
const { config } = await import('../server/config.js')
let api = client
if (!api) {
const { createAppwriteClient } = await import('./analyse-setup.mjs')
api = createAppwriteClient()
}
const featuresId = config.collections.portalFeatures
const missing = []
// Attribute einer Collection abgleichen: meldet Vorhandenes bzw. (mit --check) jede Luecke und liefert die fehlenden
async function syncAttributes(collectionId, attributes) {
const have = new Set((await api.listAttributes(collectionId)).map((a) => a.key))
for (const attr of attributes) {
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
}
return attributes.filter((a) => !have.has(a.key))
}
const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES)
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
if (check) {
log(missing.length ? `${missing.length} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
return { missing }
}
for (const attr of featureAbsent) {
await api.createAttribute(featuresId, attr)
log(`+ Attribut ${featuresId}.${attr.key}`)
}
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
log('Fertig.')
return { missing }
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const check = process.argv.includes('--check')
main({ check })
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0))
.catch((e) => {
console.error(e.message)
process.exit(1)
})
}
+1 -1
View File
@@ -286,7 +286,7 @@ export async function deleteCustomerWithPortalAccess(customerId) {
// Fehler halten das Loeschen nicht auf (wie bei der Analyse).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' })
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
+38 -9
View File
@@ -13,6 +13,8 @@ export const FEATURE_LABELS = {
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
}
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */
export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']
export const DISABLED_MESSAGE =
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
@@ -295,18 +297,44 @@ export async function activate(docId, { by, adminNote } = {}) {
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('active'),
activatedAt: new Date().toISOString(),
// Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker)
// Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker);
// Grund und Marker der Abo-Ende-Mail gehoeren zur Abschaltung und enden mit ihr. zahlungMeldung bleibt bewusst unberuehrt:
// ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen.
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
...(note ? { adminNote: note } : {}),
})
}
export async function disable(docId, { by, adminNote } = {}) {
const note = adminNote ?? byNote(by, 'Abgeschaltet')
const doc = await updateDocument(config.collections.portalFeatures, docId, {
...stateFields('disabled'),
disabledAt: new Date().toISOString(),
...(note ? { adminNote: note } : {}),
function assertReason(reason) {
if (!DISABLED_REASONS.includes(reason)) throw httpError(400, 'Unbekannter Abschaltgrund')
}
/**
* Funktion abschalten. Idempotent: steht das Dokument schon auf `disabled`, bleibt alles wie es ist (disabledAt als Beginn der
* 30-Tage-Loeschfrist, Grund, Notiz) und es gibt keinen Schreibzugriff; ein Doppelklick im WOMS, die Kundenloeschung oder ein
* zweiter Lauf verschieben die Frist also nie. Einzige Ausnahme Altbestand: fehlt einem `disabled`-Dokument disabledAt, wird nur
* dieses Feld gesetzt (sonst fiele es nie in die Loeschung). Die Nacharbeit der Analyse (Sammeldienst) laeuft bei jedem Aufruf.
* @param {string} docId
* @param {{ by?: string, adminNote?: string, reason?: 'team'|'abo_ende'|'abo_ende_vor_einrichtung'|'kunde_geloescht' }} [options]
* @throws {Error} 400 bei unbekanntem Grund (ohne Schreibzugriff), 404 bei unbekanntem Dokument
*/
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
assertReason(reason)
const doc = await withLocks([`feature-disable:${docId}`], async () => {
const current = await getDocument(config.collections.portalFeatures, docId)
if (current.state === 'disabled') {
if (current.disabledAt) return current
return updateDocument(config.collections.portalFeatures, docId, { disabledAt: new Date().toISOString() })
}
const note = adminNote ?? byNote(by, 'Abgeschaltet')
return updateDocument(config.collections.portalFeatures, docId, {
...stateFields('disabled'),
disabledAt: new Date().toISOString(),
disabledReason: reason,
...(note ? { adminNote: note } : {}),
})
})
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
return doc
@@ -318,8 +346,9 @@ export async function disable(docId, { by, adminNote } = {}) {
* abgeschaltet und danach der erste Fehler weitergereicht.
* @returns {Promise<object[]>} die abgeschalteten Dokumente
*/
export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) {
export async function disableCustomerFeatures(customerId, key, { by, adminNote, reason = 'team' } = {}) {
assertKey(key)
assertReason(reason)
const docs = (await listCustomerFeatureDocs(customerId)).filter(
(d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled'
)
@@ -327,7 +356,7 @@ export async function disableCustomerFeatures(customerId, key, { by, adminNote }
let failure = null
for (const doc of docs) {
try {
disabled.push(await disable(doc.$id, { by, adminNote }))
disabled.push(await disable(doc.$id, { by, adminNote, reason }))
} catch (err) {
failure ||= err
}
+19
View File
@@ -627,6 +627,25 @@ test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu un
assert.equal((await doc('c1', 'k7p')).state, 'disabled')
})
test('disable() idempotent (A11): die Analyse-Nacharbeit läuft bei jedem Aufruf, das Dokument bleibt beim zweiten unverändert', async () => {
aw.seed('websiteProjects', { $id: 'a11p', customerId: 'c1', projectName: 'A11P', subdomain: 'a11p', isPublic: true, repoFullName: 'WEBklar/a11p' })
const d = await seedFeature('c1', 'a11p', 'active')
const erst = await sf.disable(d.$id, { adminNote: 'eins' })
assert.equal(erst.state, 'disabled')
assert.equal(erst.disabledReason, 'team')
assert.deepEqual(stops, [d.siteKey])
const davor = await doc('c1', 'a11p')
assert.ok(davor.removalTicketId)
const zweit = await sf.disable(d.$id, { adminNote: 'zwei', reason: 'abo_ende' })
assert.deepEqual(stops, [d.siteKey, d.siteKey], 'stopSite läuft auch beim zweiten Aufruf, wie vorher')
assert.equal(zweit.state, 'disabled')
assert.deepEqual(await doc('c1', 'a11p'), davor, 'Frist, Grund, Notiz und Marker bleiben')
assert.equal(davor.disabledReason, 'team')
assert.equal(davor.adminNote, 'eins')
assert.equal(removalTopics('A11P').length, 1, 'das Entfernen-Ticket gibt es genau einmal')
})
test('Löschfrist ohne Projekt: stopSite, kein Ticket, kein Neubau, kein Fehler', async () => {
aw.seed('websiteProjects', { $id: 'k8p', customerId: 'c1', projectName: 'KP9', subdomain: 'kp9', isPublic: true })
const d = await seedFeature('c1', 'k8p', 'active')
+130
View File
@@ -0,0 +1,130 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Das Skript laedt config.js erst in main(): schema.js (Testhilfe) importiert es vor useTestEnv()
import { PORTAL_FEATURE_ATTRIBUTES, main } from '../scripts/feature-setup.mjs'
// Schema-Skript der Funktions-Attribute (scripts/feature-setup.mjs): Mock-Client wie in analyseSetup.test.js
const schema = { portalFeatures: [] } // wird im Schema-Test gefuellt (die Analyse-Schluessel kennt erst das dynamisch geladene Skript)
const aw = await startAppwriteMock({ schema })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { ANALYSE_ATTRIBUTES } = await import('../scripts/analyse-setup.mjs')
function mockClient({ attrs = [] } = {}) {
const calls = { attrs: [], collections: [], indexes: [], waits: [] }
return {
calls,
async collectionExists() {
return true
},
async ensureCollection(id, name) {
calls.collections.push({ id, name })
return true
},
async listAttributes(id) {
assert.equal(id, config.collections.portalFeatures, 'liest nur die Funktions-Collection')
return attrs.map((key) => ({ key }))
},
async createAttribute(id, attr) {
calls.attrs.push({ id, ...attr })
},
async waitForAttribute(id, key) {
calls.waits.push(`${id}.${key}`)
},
async listIndexes() {
return []
},
async createIndex(id, index) {
calls.indexes.push(`${id}.${index.key}`)
},
}
}
const KEYS = ['disabledReason', 'aboEndeMailAt', 'zahlungMeldung']
const KEINE = { attrs: [], collections: [], indexes: [], waits: [] }
test('Schema: drei Attribute mit Typ und Groesse, alle optional', () => {
assert.deepEqual(PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), KEYS)
const nach = Object.fromEntries(PORTAL_FEATURE_ATTRIBUTES.map((a) => [a.key, a]))
assert.equal(nach.disabledReason.type, 'string')
assert.equal(nach.disabledReason.size, 32)
assert.ok(nach.disabledReason.size >= 'abo_ende_vor_einrichtung'.length, 'der laengste Abschaltgrund passt hinein')
assert.ok(nach.disabledReason.size >= 24)
assert.equal(nach.aboEndeMailAt.type, 'datetime')
assert.equal('size' in nach.aboEndeMailAt, false, 'ein datetime-Attribut hat keine Groesse')
assert.equal(nach.zahlungMeldung.type, 'string')
assert.equal(nach.zahlungMeldung.size, 24)
assert.ok(nach.zahlungMeldung.size >= 'bereits_aktiv'.length)
assert.ok(PORTAL_FEATURE_ATTRIBUTES.every((a) => a.required === false), 'alle optional')
})
test('Schema: DISABLED_REASONS passen in disabledReason', async () => {
const { DISABLED_REASONS } = await import('../server/services/siteFeatures.js')
const groesse = PORTAL_FEATURE_ATTRIBUTES.find((a) => a.key === 'disabledReason').size
for (const grund of DISABLED_REASONS) assert.ok(grund.length <= groesse, grund)
})
test('main legt nur Fehlendes an und wartet auf die neuen Attribute', async () => {
const client = mockClient({ attrs: ['disabledReason', 'customerId'] })
const log = []
const result = await main({ client, log: (zeile) => log.push(zeile) })
assert.deepEqual(client.calls.attrs.map((a) => `${a.id}.${a.key}`), ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung'])
assert.deepEqual(client.calls.attrs.map((a) => a.type), ['datetime', 'string'])
assert.ok(client.calls.attrs.every((a) => a.required === false))
assert.equal(client.calls.attrs.find((a) => a.key === 'zahlungMeldung').size, 24)
assert.deepEqual(client.calls.waits, ['portalFeatures.aboEndeMailAt', 'portalFeatures.zahlungMeldung'])
assert.deepEqual([client.calls.collections, client.calls.indexes], [[], []])
assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung'])
assert.ok(log.some((zeile) => zeile.includes('+ Attribut portalFeatures.aboEndeMailAt')))
assert.ok(log.some((zeile) => zeile.includes('= portalFeatures.disabledReason (existiert)')))
})
test('main legt bei leerer Collection alle drei an, beim zweiten Lauf nichts mehr (idempotent)', async () => {
const leer = mockClient()
assert.equal((await main({ client: leer, log: () => {} })).missing.length, 3)
assert.deepEqual(leer.calls.attrs.map((a) => a.key), KEYS)
const voll = mockClient({ attrs: KEYS })
const result = await main({ client: voll, log: () => {} })
assert.deepEqual(voll.calls, KEINE)
assert.equal(result.missing.length, 0)
})
test('main --check legt nichts an, meldet jede Luecke und liefert sie im Ergebnis', async () => {
const client = mockClient({ attrs: ['disabledReason'] })
const log = []
const result = await main({ check: true, client, log: (zeile) => log.push(zeile) })
assert.deepEqual(client.calls, KEINE)
assert.deepEqual(result.missing.map((a) => a.key), ['aboEndeMailAt', 'zahlungMeldung'])
assert.ok(log.includes('- portalFeatures.aboEndeMailAt FEHLT'))
assert.ok(log.includes('- portalFeatures.zahlungMeldung FEHLT'))
assert.ok(!log.some((zeile) => zeile.includes('disabledReason FEHLT')))
const alle = mockClient({ attrs: KEYS })
const log2 = []
const heil = await main({ check: true, client: alle, log: (zeile) => log2.push(zeile) })
assert.equal(heil.missing.length, 0)
assert.ok(!log2.some((zeile) => zeile.includes('FEHLT')))
assert.deepEqual(alle.calls, KEINE)
})
test('schemaKeys(portalFeatures) enthaelt die drei Schluessel; der Analyse-Aufbau bleibt fehlerfrei', async () => {
const keys = schemaKeys('portalFeatures')
for (const key of [...KEYS, 'disabledAt', 'state', 'consentText']) assert.ok(keys.includes(key), key)
assert.equal(new Set(keys).size, keys.length, 'keine doppelten Schluessel')
// Der Aufbau aus analyse.test.js: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS]
const analyse = ANALYSE_ATTRIBUTES.map((a) => a.key)
const zusammen = [...keys, ...analyse]
// activatedAt steht schon im Grundschema und in ANALYSE_KEYS (harmlos); die neuen Schluessel gehoeren nur einer Seite
for (const key of KEYS) assert.ok(!analyse.includes(key), `${key} ist kein Analyse-Attribut`)
schema.portalFeatures.push(...zusammen)
const { createDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const doc = await createDocument(config.collections.portalFeatures, { customerId: 'c1', disabledReason: 'team', accentColor: '#111111' }, 'f_schema')
const neu = await updateDocument(config.collections.portalFeatures, doc.$id, { aboEndeMailAt: null, zahlungMeldung: '', offSince: null })
assert.equal(neu.disabledReason, 'team')
await assert.rejects(() => updateDocument(config.collections.portalFeatures, doc.$id, { gibtEsNicht: 1 }), /Unknown attribute/)
})
+15 -5
View File
@@ -71,16 +71,24 @@ test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () =>
assert.equal(after.state, 'active')
})
test('Abschalten setzt disabledAt, erneutes Aktivieren leert es (R36)', async () => {
test('Abschalten setzt disabledAt und den Grund team, erneutes Aktivieren leert beides (R36)', async () => {
const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' })
assert.equal(off.status, 200)
assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled')
const doc = await docOf('pt1', 'termine')
assert.equal(doc.enabled, false)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000)
assert.equal(doc.disabledReason, 'team', 'das WOMS schaltet mit dem Vorgabegrund ab')
// zweiter Klick (Doppelklick im WOMS): Beginn der 30 Tage, Grund und Notiz bleiben
const nochmal = await team('c1', 'termine', { projectId: 'pt1', action: 'disable', note: 'zweiter Klick' })
assert.equal(nochmal.status, 200)
const gleich = await docOf('pt1', 'termine')
assert.deepEqual([gleich.disabledAt, gleich.disabledReason, gleich.adminNote], [doc.disabledAt, 'team', doc.adminNote])
const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' })
assert.equal(featuresOf(on, 'pt1').termine.state, 'active')
assert.equal((await docOf('pt1', 'termine')).disabledAt, null)
const aktiv = await docOf('pt1', 'termine')
assert.equal(aktiv.disabledAt, null)
assert.equal(aktiv.disabledReason, null)
})
test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => {
@@ -170,7 +178,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher'
})
// --------------------------------------------------------- Kunde loeschen ---
test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur seine', async () => {
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' })
aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' })
aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' })
@@ -187,10 +195,12 @@ test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur
assert.equal(aw.docs('customers').has('kt1'), false)
for (const projekt of ['ktp1', 'ktp2']) {
const doc = await sf.getFeatureDoc('kt1', projekt, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.adminNote], ['disabled', false, 'Kunde gelöscht'], projekt)
assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], projekt)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt)
}
assert.equal((await sf.getFeatureDoc('kt1', 'ktp3', 'termine')).disabledAt, frueher, 'Beginn der 30 Tage bleibt')
const schonAusNachher = await sf.getFeatureDoc('kt1', 'ktp3', 'termine')
assert.equal(schonAusNachher.disabledAt, frueher, 'Beginn der 30 Tage bleibt')
assert.equal(schonAusNachher.disabledReason, 'team', 'ein schon abgeschalteter Terminplaner behaelt seinen Grund')
assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde')
})
+174 -1
View File
@@ -1,4 +1,4 @@
import { test, after } from 'node:test'
import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
@@ -264,3 +264,176 @@ test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab,
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, marker)
await assert.rejects(() => sf.disableCustomerFeatures('c1', 'kalender', {}), { status: 400 })
})
// ------------------------------------------------ disable(): Grund und Idempotenz (A11) ---
const PF = '/collections/portalFeatures/documents'
const termineDoc = (id, over = {}) => ({ $id: id, customerId: 'c1', projectId: `p_${id}`.slice(0, 36), featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop', ...over })
const lesen = (id) => aw.docs('portalFeatures').get(id)
const warte = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms))
/** Fuehrt fn aus und zaehlt die schreibenden Anfragen an portalFeatures (PATCH, POST, DELETE). */
async function schreibzugriffe(fn) {
const echt = globalThis.fetch
let schreibend = 0
const spion = mock.method(globalThis, 'fetch', async (url, opts = {}) => {
if (String(url).includes(PF) && String(opts.method || 'GET').toUpperCase() !== 'GET') schreibend += 1
return echt(url, opts)
})
try {
const ergebnis = await fn()
return { ergebnis, schreibend }
} finally {
spion.mock.restore()
}
}
test('DISABLED_REASONS ist die feste Liste aus dem Plankopf', () => {
assert.deepEqual(sf.DISABLED_REASONS, ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht'])
})
test('disable ohne Grund speichert team, mit Grund den Grund, ein unbekannter Grund ist 400 ohne Schreibzugriff', async () => {
aw.seed('portalFeatures', termineDoc('f_a11_grund0'))
const ohne = await sf.disable('f_a11_grund0', { by: 'kenso' })
assert.equal(ohne.state, 'disabled')
assert.equal(ohne.disabledReason, 'team')
assert.equal(ohne.adminNote, 'Abgeschaltet von kenso')
for (const grund of sf.DISABLED_REASONS) {
aw.seed('portalFeatures', termineDoc(`f_a11_${grund}`))
const doc = await sf.disable(`f_a11_${grund}`, { reason: grund })
assert.equal(doc.disabledReason, grund, grund)
assert.equal(lesen(doc.$id).disabledReason, grund, `${grund}: gespeichert`)
}
const ohneNotiz = lesen('f_a11_abo_ende')
assert.equal(ohneNotiz.adminNote, undefined, 'ohne by und adminNote keine Notiz (wie bisher)')
aw.seed('portalFeatures', termineDoc('f_a11_frist'))
for (const falsch of ['frist', '', null, 'TEAM']) {
const { schreibend } = await schreibzugriffe(() => assert.rejects(() => sf.disable('f_a11_frist', { reason: falsch }), { status: 400 }))
assert.equal(schreibend, 0, `Grund ${JSON.stringify(falsch)}: kein Schreibzugriff`)
}
const unberuehrt = lesen('f_a11_frist')
assert.equal(unberuehrt.state, 'active')
assert.equal(unberuehrt.enabled, true)
assert.equal(unberuehrt.disabledAt, undefined)
assert.equal(unberuehrt.disabledReason, undefined)
})
test('disable eines unbekannten Dokuments bleibt 404', async () => {
await assert.rejects(() => sf.disable('f_a11_gibtsnicht', { reason: 'abo_ende' }), { status: 404 })
})
test('disable ist idempotent: das zweite Mal bleibt alles, es gibt keinen Schreibzugriff und das Dokument kommt zurueck', async () => {
aw.seed('portalFeatures', termineDoc('f_a11_idem'))
const erst = await sf.disable('f_a11_idem', { reason: 'abo_ende', adminNote: 'Abo beendet' })
const davor = lesen('f_a11_idem')
await warte()
const { ergebnis: zweit, schreibend } = await schreibzugriffe(() => sf.disable('f_a11_idem', { by: 'kenso', reason: 'kunde_geloescht', adminNote: 'Kunde gelöscht' }))
assert.equal(schreibend, 0, 'schon abgeschaltet: nichts wird geschrieben')
const danach = lesen('f_a11_idem')
assert.deepEqual(danach, davor, 'das ganze Dokument unveraendert')
assert.equal(danach.disabledAt, erst.disabledAt)
assert.equal(danach.disabledReason, 'abo_ende')
assert.equal(danach.adminNote, 'Abo beendet')
assert.equal(danach.$updatedAt, davor.$updatedAt)
assert.deepEqual(zweit, davor, 'liefert das (gelesene) Dokument')
// auch ohne Angaben, auch das dritte Mal
assert.deepEqual(await sf.disable('f_a11_idem', {}), davor)
})
test('disable: zwei gleichzeitige Aufrufe schreiben einmal, beide sehen dasselbe disabledAt und denselben Grund', async () => {
aw.seed('portalFeatures', termineDoc('f_a11_parallel'))
const { ergebnis, schreibend } = await schreibzugriffe(() =>
Promise.all([
sf.disable('f_a11_parallel', { reason: 'abo_ende', adminNote: 'zuerst' }),
sf.disable('f_a11_parallel', { reason: 'kunde_geloescht', adminNote: 'danach' }),
])
)
assert.equal(schreibend, 1)
const [a, b] = ergebnis
assert.equal(a.disabledAt, b.disabledAt)
assert.equal(b.disabledReason, 'abo_ende', 'der erste Aufruf gewinnt')
assert.equal(b.adminNote, 'zuerst')
assert.equal(lesen('f_a11_parallel').disabledAt, a.disabledAt)
// viele gleichzeitige Aufrufe (Doppelklick, Kundenloeschung und Abgleich zugleich)
aw.seed('portalFeatures', termineDoc('f_a11_parallel2'))
const viele = await schreibzugriffe(() => Promise.all([1, 2, 3, 4, 5].map(() => sf.disable('f_a11_parallel2', {}))))
assert.equal(viele.schreibend, 1)
assert.equal(new Set(viele.ergebnis.map((d) => d.disabledAt)).size, 1)
})
test('disable: Altbestand disabled ohne disabledAt bekommt nur disabledAt (jetzt), Grund und Notiz bleiben', async () => {
aw.seed('portalFeatures', termineDoc('f_a11_alt', { state: 'disabled', enabled: false, adminNote: 'Altbestand' }))
const { ergebnis, schreibend } = await schreibzugriffe(() => sf.disable('f_a11_alt', { reason: 'abo_ende', adminNote: 'neu' }))
assert.equal(schreibend, 1)
assert.ok(Math.abs(Date.parse(ergebnis.disabledAt) - Date.now()) < 5000, 'disabledAt ist jetzt')
assert.equal(ergebnis.adminNote, 'Altbestand')
assert.ok(!ergebnis.disabledReason, 'der Grund bleibt leer (Altbestand wie team behandelt, nie angefasst)')
assert.equal(ergebnis.state, 'disabled')
const gesetzt = lesen('f_a11_alt')
assert.equal(gesetzt.disabledAt, ergebnis.disabledAt)
assert.ok(!gesetzt.disabledReason)
// der zweite Aufruf schreibt nichts mehr
const { ergebnis: zweit, schreibend: nochmal } = await schreibzugriffe(() => sf.disable('f_a11_alt', { reason: 'kunde_geloescht', adminNote: 'x' }))
assert.equal(nochmal, 0)
assert.equal(zweit.disabledAt, gesetzt.disabledAt)
assert.deepEqual(lesen('f_a11_alt'), gesetzt)
})
test('activate leert disabledAt, disabledReason und aboEndeMailAt und laesst zahlungMeldung unveraendert', async () => {
aw.seed(
'portalFeatures',
termineDoc('f_a11_activate', {
state: 'disabled',
enabled: false,
disabledAt: '2026-09-01T10:00:00.000Z',
disabledReason: 'abo_ende',
aboEndeMailAt: '2026-09-01T10:05:00.000Z',
zahlungMeldung: 'gemeldet',
})
)
const on = await sf.activate('f_a11_activate', { by: 'kenso' })
assert.equal(on.state, 'active')
assert.equal(on.enabled, true)
assert.equal(on.disabledAt, null)
assert.equal(on.disabledReason, null)
assert.equal(on.aboEndeMailAt, null)
assert.equal(on.zahlungMeldung, 'gemeldet', 'ein offener Zahlungsfall geht durch eine Aktivierung nicht verloren')
assert.deepEqual(
[lesen('f_a11_activate').disabledAt, lesen('f_a11_activate').disabledReason, lesen('f_a11_activate').aboEndeMailAt, lesen('f_a11_activate').zahlungMeldung],
[null, null, null, 'gemeldet']
)
// Abschalten und Einschalten im Kreis: danach beginnt eine neue Abschaltung mit neuem Grund
const wieder = await sf.disable('f_a11_activate', { reason: 'kunde_geloescht' })
assert.equal(wieder.disabledReason, 'kunde_geloescht')
assert.ok(wieder.disabledAt)
})
test('disableCustomerFeatures reicht den Grund an alle Dokumente durch, ein schon abgeschaltetes behaelt Grund und disabledAt', async () => {
const frueher = '2026-09-01T10:00:00.000Z'
aw.seed('portalFeatures', termineDoc('f_a11_k_aktiv', { customerId: 'c_a11', projectId: 'p_a11_1' }))
aw.seed('portalFeatures', termineDoc('f_a11_k_setup', { customerId: 'c_a11', projectId: 'p_a11_2', state: 'setup', enabled: false }))
aw.seed('portalFeatures', termineDoc('f_a11_k_bestellt', { customerId: 'c_a11', projectId: 'p_a11_3', state: 'ordered', enabled: false }))
aw.seed('portalFeatures', termineDoc('f_a11_k_schonaus', { customerId: 'c_a11', projectId: 'p_a11_4', state: 'disabled', enabled: false, disabledAt: frueher, disabledReason: 'team', adminNote: 'vom Team' }))
aw.seed('portalFeatures', termineDoc('f_a11_k_off', { customerId: 'c_a11', projectId: 'p_a11_5', state: 'off', enabled: false }))
aw.seed('portalFeatures', termineDoc('f_a11_k_news', { customerId: 'c_a11', projectId: 'p_a11_1', featureKey: 'news' }))
const done = await sf.disableCustomerFeatures('c_a11', 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
assert.deepEqual(done.map((d) => d.$id).sort(), ['f_a11_k_aktiv', 'f_a11_k_bestellt', 'f_a11_k_setup'])
for (const id of ['f_a11_k_aktiv', 'f_a11_k_setup', 'f_a11_k_bestellt']) {
const doc = lesen(id)
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.adminNote], ['disabled', false, 'kunde_geloescht', 'Kunde gelöscht'], id)
assert.ok(doc.disabledAt, id)
}
const schonAus = lesen('f_a11_k_schonaus')
assert.deepEqual([schonAus.disabledReason, schonAus.disabledAt, schonAus.adminNote], ['team', frueher, 'vom Team'])
assert.equal(lesen('f_a11_k_off').state, 'off')
assert.equal(lesen('f_a11_k_news').state, 'active', 'andere Funktion')
// ohne Grund: team; ein unbekannter Grund ist 400 und schreibt nichts
aw.seed('portalFeatures', termineDoc('f_a11_k2', { customerId: 'c_a11b', projectId: 'p_a11_6' }))
await assert.rejects(() => sf.disableCustomerFeatures('c_a11b', 'termine', { reason: 'frist' }), { status: 400 })
assert.equal(lesen('f_a11_k2').state, 'active')
await sf.disableCustomerFeatures('c_a11b', 'termine', {})
assert.equal(lesen('f_a11_k2').disabledReason, 'team')
})
+5 -2
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
import { PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
@@ -8,7 +9,8 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw.
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs).
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs);
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES).
*/
export function schemaKeys(collection) {
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
@@ -17,7 +19,8 @@ export function schemaKeys(collection) {
const start = src.indexOf(`'${collection}',`)
if (start < 0) continue
const block = src.slice(start, src.indexOf('\n ],', start))
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
return collection === 'portalFeatures' ? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key)] : keys
}
throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
}
+15
View File
@@ -33,6 +33,7 @@ const { neuesToken, verwaltungsToken, buchungsNummer } = await import('../server
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { belegeZellen } = await import('../server/services/termine/zellen.js')
const { bestaetige } = await import('../server/services/termine/ablauf.js')
const { disable } = await import('../server/services/siteFeatures.js')
const J = await import('../server/services/termine/jobs.js')
const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J
after(() => setMailTransportForTests(null))
@@ -1373,6 +1374,20 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei
assert.equal(aw.docs('termineConfig').get(F1), undefined)
})
test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOMS, Kundenlöschung) verschiebt die 30 Tage nicht', async () => {
const seit = plus(JETZT, -31 * TAG)
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(seit), disabledReason: 'abo_ende' })
await legeBuchung('b_fest', { start: plus(JETZT, TAG) })
await disable(F1, { by: 'Team' })
await disable(F1, { reason: 'kunde_geloescht', adminNote: 'Kunde gelöscht' })
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(Date.parse(doc.disabledAt), seit.getTime(), 'disabledAt (Beginn der Frist) bleibt')
assert.equal(doc.disabledReason, 'abo_ende')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }, 'die Löschung läuft weiter')
assert.equal(buchung('b_fest'), undefined)
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
})
/** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `<praefix>000…`; mit zellen bekommt jede ihre Zellen. */
async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab' } = {}) {
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })