diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index e4e4f82..d9d1a22 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -2,7 +2,7 @@ /** * Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits * (Uebernahmegutschriften), aboOffers (individuelle Angebote), portalFeatures (Portal-Funktionen je - * Projekt) und newsPosts (News-Pinnwand) in woms-database an und + * Projekt), newsPosts (News-Pinnwand) und customerViewLog (Protokoll der Kundenansicht) in woms-database an und * ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto"). * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. * Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs @@ -333,4 +333,22 @@ await ensure('worksheets.countsToPlan', () => ) await waitForAttributes('worksheets', ['countsToPlan']) +// Kundenansicht: wer wann das Portal eines Kunden geoeffnet oder darin etwas geaendert hat (12 Monate) +await ensureCollection( + 'customerViewLog', + 'Kundenansicht-Protokoll', + [ + { type: 'string', key: 'customerId', size: 64 }, + { type: 'string', key: 'staffUserId', size: 64 }, + { type: 'string', key: 'staffName', size: 128 }, + { type: 'string', key: 'action', size: 32 }, + { type: 'string', key: 'detail', size: 300 }, + { type: 'datetime', key: 'createdAt' }, + ], + [ + { key: 'idx_customer_created', type: 'key', attributes: ['customerId', 'createdAt'] }, + { key: 'idx_created', type: 'key', attributes: ['createdAt'] }, + ] +) + console.log('Fertig.') diff --git a/server/config.js b/server/config.js index a4b5b89..d7ca60d 100644 --- a/server/config.js +++ b/server/config.js @@ -154,6 +154,7 @@ export const config = { aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers', contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents', newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts', + customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog', // Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten) bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections', bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions', diff --git a/server/index.js b/server/index.js index d7e94d7..5deed6d 100644 --- a/server/index.js +++ b/server/index.js @@ -20,6 +20,7 @@ import funktionenRoutes from './routes/funktionen.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' +import { purgeViewLog } from './services/customerViewLog.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' @@ -145,6 +146,14 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(purge, 60 * 1000).unref() setInterval(purge, 24 * 60 * 60 * 1000).unref() + // Protokoll der Kundenansicht: Eintraege aelter als 12 Monate loeschen (taeglich) + const purgeViewLogJob = () => + purgeViewLog() + .then((r) => r.deleted && console.log(`[kundenansicht] ${r.deleted} Protokolleinträge gelöscht`)) + .catch((err) => console.warn('[kundenansicht] Aufräumen fehlgeschlagen:', err.message)) + setTimeout(purgeViewLogJob, 150 * 1000).unref() + setInterval(purgeViewLogJob, 24 * 60 * 60 * 1000).unref() + // Chat-Anhaenge, die nie abgeschickt wurden, nach config.chat.pendingTtlHours loeschen (stuendlich) const purgeChat = () => purgePendingChatUploads() diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index 0343c26..b116c85 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -76,6 +76,7 @@ export const Query = { }), limit: (n) => ({ method: 'limit', values: [n] }), greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }), + lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), } diff --git a/server/services/customerViewLog.js b/server/services/customerViewLog.js new file mode 100644 index 0000000..77b0549 --- /dev/null +++ b/server/services/customerViewLog.js @@ -0,0 +1,54 @@ +import { config } from '../config.js' +import { createDocument, deleteDocument, listDocuments, Query } from './appwriteAdmin.js' + +/** + * Protokoll der Kundenansicht (Datenschutzerklaerung Abschnitt 6): wer wann das Portal eines Kunden geoeffnet + * oder darin etwas geaendert hat. Schreiben nur ueber den Server-Schluessel; Eintraege leben 12 Monate. + */ +export const VIEW_ACTIONS = ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'] + +const DETAIL_MAX = 300 +const RETENTION_MS = 365 * 24 * 60 * 60 * 1000 +const PAGE = 100 + +const COLLECTION = () => config.collections.customerViewLog + +/** Eintrag schreiben; wirft nie (die protokollierte Aktion selbst bleibt gueltig). */ +export async function logViewAction({ customerId, staffUserId, staffName, action, detail = '' }) { + try { + await createDocument(COLLECTION(), { + customerId: String(customerId || ''), + staffUserId: String(staffUserId || ''), + staffName: String(staffName || ''), + action: String(action || ''), + detail: String(detail || '').slice(0, DETAIL_MAX), + createdAt: new Date().toISOString(), + }) + } catch (err) { + console.warn(`[kundenansicht] Protokoll (${action}, Kunde ${customerId}) nicht geschrieben:`, err.message) + } +} + +/** Eintraege eines Kunden, neueste zuerst. */ +export async function listViewLog(customerId, { limit = 200 } = {}) { + const docs = await listDocuments(COLLECTION(), [ + Query.equal('customerId', customerId), + Query.orderDesc('createdAt'), + Query.limit(limit), + ]) + return docs.map((d) => ({ id: d.$id, at: d.createdAt, staffName: d.staffName || '', action: d.action || '', detail: d.detail || '' })) +} + +/** Eintraege aelter als 12 Monate loeschen (taeglicher Lauf), seitenweise bis keine mehr uebrig sind. */ +export async function purgeViewLog({ now = new Date() } = {}) { + const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString() + let deleted = 0 + for (;;) { + const docs = await listDocuments(COLLECTION(), [Query.lessThan('createdAt', cutoff), Query.limit(PAGE)]) + for (const doc of docs) { + await deleteDocument(COLLECTION(), doc.$id) + deleted += 1 + } + if (docs.length < PAGE) return { deleted } + } +} diff --git a/test/customerViewLog.test.js b/test/customerViewLog.test.js new file mode 100644 index 0000000..e2865c7 --- /dev/null +++ b/test/customerViewLog.test.js @@ -0,0 +1,73 @@ +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' + +// Protokoll der Kundenansicht (Collection customerViewLog): schreiben, lesen, nach 12 Monaten loeschen +const aw = await startAppwriteMock({ schema: { customerViewLog: schemaKeys('customerViewLog') } }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { VIEW_ACTIONS, logViewAction, listViewLog, purgeViewLog } = await import('../server/services/customerViewLog.js') +after(() => Promise.all([aw.close(), st.close()])) + +const DAY_MS = 24 * 60 * 60 * 1000 +const entries = () => [...aw.docs('customerViewLog').values()] +const seedLog = (id, customerId, createdAt, action = 'geoeffnet') => + aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt }) + +test('VIEW_ACTIONS: genau die sieben Aktionen', () => { + assert.deepEqual(VIEW_ACTIONS, ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht']) +}) + +test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => { + const before = entries().length + await logViewAction({ customerId: 'c9', staffUserId: 'u_staff2', staffName: 'Andrej', action: 'news_angelegt', detail: 'x'.repeat(400) }) + const all = entries() + assert.equal(all.length, before + 1) + const doc = all.find((d) => d.customerId === 'c9') + assert.equal(doc.detail.length, 300) + assert.equal(doc.action, 'news_angelegt') + assert.equal(doc.staffName, 'Andrej') + assert.equal(doc.staffUserId, 'u_staff2') + assert.ok(Math.abs(Date.parse(doc.createdAt) - Date.now()) < 5000) +}) + +test('logViewAction wirft nicht, wenn Appwrite scheitert', async (t) => { + const warn = t.mock.method(console, 'warn', () => {}) + aw.failNext({ collection: 'customerViewLog', method: 'POST', status: 500, message: 'kaputt' }) + await assert.doesNotReject(() => logViewAction({ customerId: 'c8', staffUserId: 'u', staffName: 'X', action: 'geoeffnet' })) + assert.equal(entries().filter((d) => d.customerId === 'c8').length, 0) + assert.equal(warn.mock.callCount(), 1) +}) + +test('listViewLog: nur dieser Kunde, neueste zuerst, Form {id, at, staffName, action, detail}', async () => { + seedLog('l1', 'c1', '2026-09-01T10:00:00.000Z') + seedLog('l2', 'c1', '2026-09-03T10:00:00.000Z', 'news_angelegt') + seedLog('l3', 'c1', '2026-09-02T10:00:00.000Z', 'beendet') + seedLog('l4', 'c2', '2026-09-04T10:00:00.000Z') + const list = await listViewLog('c1') + assert.deepEqual(list.map((e) => e.id), ['l2', 'l3', 'l1']) + assert.deepEqual(list[0], { id: 'l2', at: '2026-09-03T10:00:00.000Z', staffName: 'Andrej', action: 'news_angelegt', detail: '' }) +}) + +test('purgeViewLog loescht nur Eintraege aelter als 365 Tage', async () => { + const now = new Date('2026-10-01T12:00:00.000Z') + seedLog('p1', 'c3', new Date(now - 366 * DAY_MS).toISOString()) + seedLog('p2', 'c3', new Date(now - 364 * DAY_MS).toISOString()) + seedLog('p3', 'c3', new Date(now - 1 * DAY_MS).toISOString()) + const { deleted } = await purgeViewLog({ now }) + assert.equal(deleted, 1) + assert.deepEqual(entries().filter((d) => d.customerId === 'c3').map((d) => d.$id).sort(), ['p2', 'p3']) +}) + +test('purgeViewLog arbeitet seitenweise (250 alte Eintraege -> deleted 250)', async () => { + const now = new Date('2026-10-01T12:00:00.000Z') + const old = new Date(now - 400 * DAY_MS).toISOString() + for (let i = 0; i < 250; i += 1) seedLog(`old${i}`, 'c4', old) + seedLog('keep', 'c4', now.toISOString()) + const { deleted } = await purgeViewLog({ now }) + assert.equal(deleted, 250) + assert.deepEqual(entries().filter((d) => d.customerId === 'c4').map((d) => d.$id), ['keep']) +})