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:
1 parent
772095ab19
commit
fdf594706d
6 files changed
+157
-1
No files matched your search
@@ -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.')
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user