Kundenansicht: News-Aenderungen mit Mitarbeiter und Titel protokollieren
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25f3c1df4d
commit
7d8ba56088
3 files changed
+120
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user