Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:30:47 +00:00
1 parent 25f3c1df4d
commit 7d8ba56088
3 files changed
+120

No files matched your search

+12
View File
@@ -4,6 +4,9 @@ import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { NEWS_IMAGE_MAX_BYTES, newsImagePath, newsImageRateLimited, storeNewsImage } from '../services/newsImages.js'
import { createPost, deletePost, listPosts, updatePost } from '../services/newsPosts.js'
import { uploadSlot } from './chat.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
const router = Router()
@@ -41,6 +44,9 @@ function limitWrites(req, res, next) {
return next()
}
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
@@ -78,6 +84,7 @@ router.get('/', requireSession, withProject(), async (req, res) => {
router.post('/', requireSession, withProject(), limitWrites, async (req, res) => {
try {
const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body })
await logIfKundenansicht(req, 'news_angelegt', postLabel(post))
res.status(201).json(post)
} catch (err) {
fail(res, err)
@@ -100,6 +107,7 @@ router.post(
async (req, res) => {
try {
const image = await storeNewsImage({ customerId: getSessionCustomerId(req), projectId: req.projectId, buffer: req.body })
await logIfKundenansicht(req, 'news_bild')
res.status(201).json(image)
} catch (err) {
fail(res, err)
@@ -126,6 +134,7 @@ router.get('/image/:projectId/:file', requireSession, async (req, res) => {
router.patch('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
try {
const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body })
await logIfKundenansicht(req, 'news_geaendert', postLabel(post))
res.json(post)
} catch (err) {
fail(res, err)
@@ -134,7 +143,10 @@ router.patch('/:id', requireSession, withProject(), limitWrites, async (req, res
router.delete('/:id', requireSession, withProject(), limitWrites, async (req, res) => {
try {
// Titel fuers Protokoll vor dem Loeschen lesen (deletePost prueft den Besitz danach selbst)
const before = req.session.viewId ? await getDocument(config.collections.newsPosts, req.params.id).catch(() => null) : null
await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id })
await logIfKundenansicht(req, 'news_geloescht', postLabel(before))
res.status(204).end()
} catch (err) {
fail(res, err)
+13
View File
@@ -61,6 +61,19 @@ export function endKundenansicht(res) {
clearPreviewSession(res)
}
/** Aenderung in der Kundenansicht protokollieren; ausserhalb einer Kundenansicht (Kunde selbst) passiert nichts. */
export async function logIfKundenansicht(req, action, detail = '') {
const session = req.session
if (!isKundenansicht(session)) return
await logViewAction({
customerId: session.customerId,
staffUserId: session.appwriteUserId,
staffName: session.adminName,
action,
detail,
})
}
// "beendet"/"abgelaufen" je Ansicht nur einmal protokollieren (parallele Anfragen eines Tabs)
const LOGGED_TTL_MS = 3 * 60 * 60 * 1000
const logged = new Map()
+95
View File
@@ -0,0 +1,95 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync } from 'node:fs'
import sharp from 'sharp'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Kundenansicht: News-Aenderungen eines Mitarbeiters landen mit Titel im Protokoll, die des Kunden nicht
process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-news-'))
const aw = await startAppwriteMock({
schema: {
customerViewLog: schemaKeys('customerViewLog'),
portalFeatures: schemaKeys('portalFeatures'),
newsPosts: schemaKeys('newsPosts'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { startTestApp } = await import('./support/testApp.js')
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: newsRoutes } = await import('../server/routes/news.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/news': newsRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' })
aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const VIEW = 'a'.repeat(32)
const staff = { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': VIEW } }
const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' }
const call = async (method, url, { cookie, headers = {}, body } = {}) => {
const raw = Buffer.isBuffer(body)
const res = await fetch(app.url + url, {
method,
headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined && !raw ? { 'content-type': 'application/json' } : {}), ...headers },
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
})
const text = await res.text()
return { status: res.status, body: text ? JSON.parse(text) : null }
}
const logOf = (customerId) => [...aw.docs('customerViewLog').values()].filter((d) => d.customerId === customerId)
const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer()
test('anlegen, aendern, Foto, loeschen in der Kundenansicht -> vier Protokolleintraege', async () => {
const before = logOf('c1').length
const created = await call('POST', '/api/news', { ...staff, body: note })
assert.equal(created.status, 201)
const id = created.body.$id
const patched = await call('PATCH', `/api/news/${id}`, { ...staff, body: { ...note, title: 'Neu' } })
assert.equal(patched.status, 200)
const image = await call('POST', '/api/news/image?projectId=p1', {
...staff,
headers: { ...staff.headers, 'content-type': 'application/octet-stream' },
body: await png(),
})
assert.equal(image.status, 201)
const removed = await call('DELETE', `/api/news/${id}?projectId=p1`, staff)
assert.equal(removed.status, 204)
const entries = logOf('c1').slice(before)
assert.deepEqual(entries.map((e) => [e.action, e.detail]), [
['news_angelegt', 'Hallo'],
['news_geaendert', 'Neu'],
['news_bild', ''],
['news_geloescht', 'Neu'],
])
for (const e of entries) assert.deepEqual([e.staffName, e.staffUserId], ['Andrej', 'u_staff2'])
})
test('ohne Titel: Detail sind die ersten 60 Zeichen des Textes', async () => {
const before = logOf('c1').length
const text = 'Heute frische Brötchen bis 18 Uhr und morgen geschlossen wegen Inventur, danke!'
const res = await call('POST', '/api/news', { ...staff, body: { ...note, title: '', text } })
assert.equal(res.status, 201)
assert.equal(logOf('c1').slice(before)[0].detail, text.slice(0, 60))
})
test('Kunde selbst schreibt -> kein Protokolleintrag', async () => {
const before = logOf('c1').length
const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note })
assert.equal(res.status, 201)
assert.equal(logOf('c1').length, before)
})
test('Protokoll scheitert -> News-Aktion trotzdem 201', async (t) => {
t.mock.method(console, 'warn', () => {})
aw.failNext({ collection: 'customerViewLog', method: 'POST', status: 500, message: 'kaputt' })
const res = await call('POST', '/api/news', { ...staff, body: note })
assert.equal(res.status, 201)
})