diff --git a/server/routes/news.js b/server/routes/news.js index 39b9db1..151c859 100644 --- a/server/routes/news.js +++ b/server/routes/news.js @@ -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) diff --git a/server/services/kundenansicht.js b/server/services/kundenansicht.js index 4186a3b..0cf957c 100644 --- a/server/services/kundenansicht.js +++ b/server/services/kundenansicht.js @@ -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() diff --git a/test/kundenansichtNews.test.js b/test/kundenansichtNews.test.js new file mode 100644 index 0000000..63fe24b --- /dev/null +++ b/test/kundenansichtNews.test.js @@ -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) +})