Kundenansicht: Protokoll-Collection customerViewLog mit Schreiben, Lesen und Loeschen nach 12 Monaten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:25:26 +00:00
1 parent 772095ab19
commit fdf594706d
6 files changed
+157 -1

No files matched your search

+19 -1
View File
@@ -2,7 +2,7 @@
/** /**
* Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits * Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
* (Uebernahmegutschriften), aboOffers (individuelle Angebote), portalFeatures (Portal-Funktionen je * (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"). * ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs * 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']) 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.') console.log('Fertig.')
+1
View File
@@ -154,6 +154,7 @@ export const config = {
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers', aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents', contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts', newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
customerViewLog: process.env.APPWRITE_COLLECTION_CUSTOMER_VIEW_LOG || 'customerViewLog',
// Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten) // Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten)
bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections', bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections',
bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions', bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions',
+9
View File
@@ -20,6 +20,7 @@ import funktionenRoutes from './routes/funktionen.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js' import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgePendingChatUploads } from './services/chatAttachments.js'
import { purgeViewLog } from './services/customerViewLog.js'
import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js'
import { runActivation } from './services/siteFeatures.js' import { runActivation } from './services/siteFeatures.js'
import { runAboCheck, runAnalyseReconcile } from './services/analyse.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() setTimeout(purge, 60 * 1000).unref()
setInterval(purge, 24 * 60 * 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) // Chat-Anhaenge, die nie abgeschickt wurden, nach config.chat.pendingTtlHours loeschen (stuendlich)
const purgeChat = () => const purgeChat = () =>
purgePendingChatUploads() purgePendingChatUploads()
+1
View File
@@ -76,6 +76,7 @@ export const Query = {
}), }),
limit: (n) => ({ method: 'limit', values: [n] }), limit: (n) => ({ method: 'limit', values: [n] }),
greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }), greaterThan: (attribute, value) => ({ method: 'greaterThan', attribute, values: [value] }),
lessThan: (attribute, value) => ({ method: 'lessThan', attribute, values: [value] }),
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }), orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }), orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
} }
+54
View File
@@ -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 }
}
}
+73
View File
@@ -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'])
})