diff --git a/server/index.js b/server/index.js index 58187be..9331ac4 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import adminPreviewOpenRoutes from './routes/admin/previewOpen.js' import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' +import adminAnalyseRoutes from './routes/admin/analyse.js' import adminSwitchLinkRoutes from './routes/admin/switchLink.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -83,6 +84,7 @@ app.use('/api/admin/employees', adminEmployeesRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/preview-open', adminPreviewOpenRoutes) app.use('/api/admin/switch-link', adminSwitchLinkRoutes) +app.use('/api/admin/analyse', adminAnalyseRoutes) app.use('/webhook', giteaWebhookRoutes) // Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token) app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes) diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js new file mode 100644 index 0000000..1bf49cb --- /dev/null +++ b/server/routes/admin/analyse.js @@ -0,0 +1,49 @@ +// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. AVV wird mündlich bestätigt. +import { Router } from 'express' +import { config } from '../../config.js' +import { getDocument } from '../../services/appwriteAdmin.js' +import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { activateAnalyse, deactivateAnalyse } from '../../services/analyse.js' + +const router = Router() +router.use(requireAppwriteStaff) + +function sendError(res, err) { + const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 + if (status === 500) console.error('[admin-analyse]', err.message) + res.status(status).json({ error: status === 500 ? 'Die Analyse konnte nicht geändert werden' : err.message }) +} + +router.post('/:projectId', async (req, res) => { + try { + if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' }) + const { on, avvConfirmedVerbally } = req.body || {} + if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' }) + if (on && avvConfirmedVerbally !== true) { + return res.status(400).json({ error: 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat' }) + } + let project + try { + project = await getDocument(config.collections.websiteProjects, req.params.projectId) + } catch (err) { + if (err.status === 404) return res.status(404).json({ error: 'Projekt nicht gefunden' }) + throw err + } + const customerId = project.customerId + if (!customerId) return res.status(400).json({ error: 'Projekt hat keinen Kunden' }) + const by = String(req.appwriteUser?.name || req.appwriteUser?.email || 'Mitarbeiter').slice(0, 100) + const { doc } = on + ? await activateAnalyse({ + customerId, + projectId: project.$id, + consent: { accepted: true, avvVersion: config.analyse.avvVersion }, + by, + }) + : await deactivateAnalyse({ customerId, projectId: project.$id, by }) + res.json({ ok: true, state: doc?.state || 'off' }) + } catch (err) { + sendError(res, err) + } +}) + +export default router diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 421a63f..d9db219 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -14,18 +14,54 @@ import { runActivation, } from '../services/siteFeatures.js' +import { + ELIGIBILITY_MESSAGES, + activateAnalyse, + analyseDeps, + analyseDomains, + analyseEligibility, + deactivateAnalyse, +} from '../services/analyse.js' + const router = Router() const DAY_MS = 24 * 60 * 60 * 1000 // Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder // unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung. function fail(res, err, fallback) { - const own = err.status >= 400 && err.status < 500 && !err.type + const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type if (own) return res.status(err.status).json({ error: err.message }) console.error('[funktionen]', err.status || '', err.type || '', err.message) return res.status(500).json({ error: fallback }) } +const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet' +const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' + +async function analyseView(customerId, projectId) { + if (!config.analyse.enabled) return { comingSoon: true } + const [doc, eligibility] = await Promise.all([getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId)]) + return { + state: doc?.state || 'off', + accentColor: doc?.accentColor || '#111111', + eligible: eligibility.ok, + reason: eligibility.reason || null, + reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null, + avvVersion: config.analyse.avvVersion, + } +} + +// Schalter aus: alle Analyse-Routen 409 +function requireAnalyseEnabled(_req, res, next) { + if (!config.analyse.enabled) return res.status(409).json({ error: ANALYSE_OFF }) + next() +} + +function analyseProjectId(value) { + if (typeof value !== 'string' || !value) throw Object.assign(new Error('Bitte wähle ein Projekt.'), { status: 400 }) + return value +} + // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet router.get('/', requireSession, async (req, res) => { try { @@ -33,8 +69,10 @@ router.get('/', requireSession, async (req, res) => { // Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message)) await backfillInvoiceLinks(customerId) + const projects = await listCustomerFeatures(customerId) + for (const p of projects) p.analyse = await analyseView(customerId, p.projectId) res.json({ - projects: await listCustomerFeatures(customerId), + projects, setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, }) @@ -110,4 +148,90 @@ router.post('/news/checkout', requireSession, async (req, res) => { } }) +const analyseGuard = [requireSession, requireAnalyseEnabled] + +router.post('/analyse/activate', analyseGuard, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' }) + } + const { projectId, accentColor, consent } = req.body || {} + const { doc } = await activateAnalyse({ + customerId: getSessionCustomerId(req), + projectId: analyseProjectId(projectId), + accentColor, + consent, + by: 'customer', + }) + res.json({ ok: true, state: doc.state }) + } catch (err) { + fail(res, err, ANALYSE_ERROR) + } +}) + +router.post('/analyse/deactivate', analyseGuard, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' }) + } + const { doc } = await deactivateAnalyse({ + customerId: getSessionCustomerId(req), + projectId: analyseProjectId(req.body?.projectId), + by: 'customer', + }) + res.json({ ok: true, state: doc?.state || 'off' }) + } catch (err) { + fail(res, err, ANALYSE_ERROR) + } +}) + +// Eigenes Projekt und Analyse-Dokument mit siteKey (aktiv oder aus: Daten liegen noch bis zu 60 Tage) +async function ownAnalyseDoc(req, projectId) { + const customerId = getSessionCustomerId(req) + const project = await assertOwnProject(customerId, analyseProjectId(projectId)) + const doc = await getFeatureDoc(customerId, projectId, 'analyse') + return { project, doc } +} + +router.get('/analyse/stats', analyseGuard, async (req, res) => { + try { + const range = String(req.query.range || '30') + if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum' }) + const { doc } = await ownAnalyseDoc(req, req.query.projectId) + if (!doc?.siteKey || !['active', 'off'].includes(doc.state)) { + return res.status(409).json({ error: 'Für dieses Projekt gibt es keine Auswertung.' }) + } + let stats + try { + stats = await analyseDeps.client.getStats(doc.siteKey, Number(range)) + } catch (err) { + console.error('[funktionen] Auswertung:', err.status || '', err.message) + return res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' }) + } + res.json(stats) + } catch (err) { + fail(res, err, 'Die Auswertung konnte gerade nicht geladen werden.') + } +}) + +router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => { + try { + const { projectId, path, vp } = req.body || {} + if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#]/.test(path)) { + return res.status(400).json({ error: 'Ungültiger Pfad' }) + } + if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' }) + const { project, doc } = await ownAnalyseDoc(req, projectId) + if (!doc?.siteKey || doc.state !== 'active') { + return res.status(409).json({ error: 'Die Website-Analyse ist für dieses Projekt nicht aktiv.' }) + } + const domains = analyseDomains(project) + const host = project.liveDomain ? domains[domains.length - 1] : domains[0] + const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp }) + res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}` }) + } catch (err) { + fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.') + } +}) + export default router diff --git a/server/services/analyse.js b/server/services/analyse.js index c2422fa..1d23f9b 100644 --- a/server/services/analyse.js +++ b/server/services/analyse.js @@ -183,12 +183,14 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons }) } -export async function deactivateAnalyse({ customerId, projectId, now = new Date() }) { +export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer' }) { return withLocks([`analyse:${projectId}`], async () => { const project = await loadOwnProject(customerId, projectId) if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt) const existing = await getFeatureDoc(customerId, projectId, FEATURE_KEY) if (!existing || existing.state === 'off') return { doc: existing, changed: false } + // Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter) + if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false } if (existing.siteKey) { await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false }) } diff --git a/server/services/retention.js b/server/services/retention.js index 060a430..30dcec4 100644 --- a/server/services/retention.js +++ b/server/services/retention.js @@ -4,7 +4,6 @@ import { config } from '../config.js' import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js' import { deleteNewsImage } from './newsImages.js' import { disable } from './siteFeatures.js' -import { analyseDeps, analyseDomains } from './analyse.js' import { getStripe } from './stripeBilling.js' import { filesOf, STATUS } from './takeoverCredits.js' @@ -220,18 +219,8 @@ export async function applyDeletions(plan) { } for (const id of entry.featureIds) { const off = await attempt('portalFeatures', id, async () => { - const feature = await getDocument(FEATURES(), id).catch(() => null) + // disable() meldet bei Analyse auch den Sammeldienst ab (best effort) await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' }) - // Analyse: auch den Sammeldienst abschalten (best effort, die Daten loescht dessen 60-Tage-Job) - if (feature?.featureKey === 'analyse' && feature.siteKey) { - try { - // der Sammeldienst verlangt mindestens eine Domain, auch beim Abschalten - const project = await getDocument(config.collections.websiteProjects, feature.projectId) - await analyseDeps.client.putSite(feature.siteKey, { projectId: feature.projectId, domains: analyseDomains(project), active: false }) - } catch (err) { - console.warn(`[retention] Sammeldienst für Analyse ${id} nicht abgeschaltet:`, err.message) - } - } return true }) if (off) deleted.features += 1 diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 45fb2b5..4848610 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -293,11 +293,25 @@ export async function activate(docId, { by, adminNote } = {}) { export async function disable(docId, { by, adminNote } = {}) { const note = adminNote ?? byNote(by, 'Abgeschaltet') - return updateDocument(config.collections.portalFeatures, docId, { + const doc = await updateDocument(config.collections.portalFeatures, docId, { ...stateFields('disabled'), disabledAt: new Date().toISOString(), ...(note ? { adminNote: note } : {}), }) + if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc) + return doc +} + +// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert. +// Dynamischer Import: analyse.js importiert dieses Modul. +async function stopAnalyseCollecting(doc) { + try { + const { analyseDeps, analyseDomains } = await import('./analyse.js') + const project = await getDocument(config.collections.websiteProjects, doc.projectId) + await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false }) + } catch (err) { + console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message) + } } /** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */ diff --git a/test/analyseRoutes.test.js b/test/analyseRoutes.test.js new file mode 100644 index 0000000..b6d162a --- /dev/null +++ b/test/analyseRoutes.test.js @@ -0,0 +1,220 @@ +import { test, after, beforeEach } 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' +import { mailSink } from './support/mailSink.js' + +const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt'] +const aw = await startAppwriteMock({ + schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, get, rawGet, post } = await import('./support/testApp.js') +const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js') +const an = await import('../server/services/analyse.js') +const sf = await import('../server/services/siteFeatures.js') +const { verifyHeatToken } = await import('../analytics/src/heat.js') +const { createAnalyticsClient } = await import('../server/services/analyticsClient.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/admin/analyse': adminAnalyseRoutes }) +setMailTransportForTests(mailSink()) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +const puts = [] +const statsCalls = [] +let statsFail = false +const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' }) +an.analyseDeps.client = { + putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), + getStats: async (siteKey, range) => { + statsCalls.push({ siteKey, range }) + if (statsFail) throw Object.assign(new Error('x'), { status: 502 }) + return { pv: 5 } + }, + heatToken: (a) => realClient.heatToken(a), +} +an.analyseDeps.rebuild = async () => {} +config.analyse.enabled = true +config.analyse.heatSecret = 'h'.repeat(32) + +seedFixtures(aw) +aw.seed('subscriptions', wartungDoc) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true }) +aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false }) +aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Café', subdomain: 'cafe', isPublic: true }) +aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd', isPublic: true }) + +const cookie = customerCookie('c1') +const A = '/api/funktionen/analyse' +const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) +const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } }) +const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') + +beforeEach(() => { + puts.length = 0 + statsCalls.length = 0 + statsFail = false + config.analyse.enabled = true +}) + +test('Auth: ohne Anmeldung 401', async () => { + assert.equal((await post(`${A}/activate`, { projectId: 'p1' })).status, 401) + assert.equal((await rawGet(`${A}/stats?projectId=p1`)).status, 401) + assert.equal((await post(`${A}/heatmap-link`, {})).status, 401) +}) + +test('Eigentum: fremdes Projekt 403', async () => { + assert.equal((await post(`${A}/activate`, { projectId: 'p5', consent: consent() }, { cookie })).status, 403) + assert.equal((await post(`${A}/deactivate`, { projectId: 'p5' }, { cookie })).status, 403) + assert.equal((await rawGet(`${A}/stats?projectId=p5`, { cookie })).status, 403) + assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p5', path: '/', vp: 'd' }, { cookie })).status, 403) +}) + +test('Schalter aus: alle Analyse-Routen 409', async () => { + config.analyse.enabled = false + for (const r of [ + post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }), + post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }), + rawGet(`${A}/stats?projectId=p1`, { cookie }), + post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }), + adminPost('p1', { on: true, avvConfirmedVerbally: true }), + ]) { + const res = await r + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Website-Analyse ist noch nicht freigeschaltet') + } + assert.equal(await featureDoc('c1', 'p1'), null) +}) + +test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', async () => { + const body = await get('/api/funktionen', { cookie }) + const p = (id) => body.projects.find((x) => x.projectId === id) + assert.equal(p('p1').features.news.state, 'off') + assert.deepEqual(p('p1').analyse, { state: 'off', accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion }) + assert.equal(p('p2').analyse.eligible, false) + assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') + assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) + config.analyse.enabled = false + const off = await get('/api/funktionen', { cookie }) + for (const x of off.projects) assert.deepEqual(x.analyse, { comingSoon: true }) + assert.equal(off.projects[0].features.news.state, 'off') +}) + +test('Aktivieren und Deaktivieren (Kunde)', async () => { + const bad = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }, { cookie }) + assert.equal(bad.status, 400) + const ok = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: consent() }, { cookie }) + assert.equal(ok.status, 200) + assert.deepEqual(ok.body, { ok: true, state: 'active' }) + assert.equal(puts[0].active, true) + assert.equal((await featureDoc('c1', 'p1')).accentColor, '#112233') + const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse + assert.equal(view.state, 'active') + assert.equal(view.accentColor, '#112233') + const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) + assert.deepEqual(off.body, { ok: true, state: 'off' }) + assert.equal(puts[1].active, false) + const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie }) + assert.equal(notElig.status, 409) +}) + +test('Stats: Weiterleitung, Zeitraum, 503 bei Ausfall', async () => { + const p3 = await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie }) + assert.equal(p3.status, 200) + const key = (await featureDoc('c1', 'p3')).siteKey + const ok = await rawGet(`${A}/stats?projectId=p3&range=7`, { cookie }) + assert.equal(ok.status, 200) + assert.deepEqual(ok.body, { pv: 5 }) + assert.deepEqual(statsCalls[0], { siteKey: key, range: 7 }) + assert.equal((await rawGet(`${A}/stats?projectId=p3&range=5`, { cookie })).status, 400) + statsFail = true + const down = await rawGet(`${A}/stats?projectId=p3`, { cookie }) + assert.equal(down.status, 503) + assert.equal(down.body.error, 'Auswertung gerade nicht erreichbar') + statsFail = false + // aus, Daten noch da: weiter erlaubt + await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie }) + assert.equal((await rawGet(`${A}/stats?projectId=p3`, { cookie })).status, 200) + // nie aktiviert: keine Auswertung + aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Neu', subdomain: 'neu', isPublic: true }) + assert.equal((await rawGet(`${A}/stats?projectId=p6`, { cookie })).status, 409) +}) + +test('Heatmap-Link: Prüfung, URL, Token', async () => { + await post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }) + const key = (await featureDoc('c1', 'p1')).siteKey + const link = (b) => post(`${A}/heatmap-link`, { projectId: 'p1', ...b }, { cookie }) + for (const path of ['start', '/a?x=1', '/a#b', '/' + 'a'.repeat(300), 5]) assert.equal((await link({ path, vp: 'd' })).status, 400, String(path)) + assert.equal((await link({ path: '/', vp: 'x' })).status, 400) + const ok = await link({ path: '/preise', vp: 'm' }) + assert.equal(ok.status, 200) + const url = new URL(ok.body.url) + assert.equal(url.origin, 'https://baeckerei.de') + assert.equal(url.pathname, '/preise') + const v = verifyHeatToken(url.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now()) + assert.equal(v.siteKey, key) + assert.equal(v.path, '/preise') + assert.equal(v.vp, 'm') + // ohne Live-Domain: Subdomain + await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie }) + const sub = await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie }) + assert.equal(new URL(sub.body.url).origin, 'https://cafe.project.webklar.com') + // nicht aktiv: 409 + await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie }) + assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })).status, 409) +}) + +test('Admin: 401, 400, 200', async () => { + assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401) + assert.equal((await adminPost('p6', { on: true, avvConfirmedVerbally: true }, 'jwt-kunde')).status, 403) + const noAvv = await adminPost('p6', { on: true }) + assert.equal(noAvv.status, 400) + assert.equal(noAvv.body.error, 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat') + assert.equal((await adminPost('p6', { on: 'ja' })).status, 400) + assert.equal((await adminPost('nix', { on: true, avvConfirmedVerbally: true })).status, 404) + const on = await adminPost('p6', { on: true, avvConfirmedVerbally: true }) + assert.equal(on.status, 200) + assert.deepEqual(on.body, { ok: true, state: 'active' }) + const d = await featureDoc('c1', 'p6') + assert.equal(d.avvAcceptedBy, 'kenso@webklar.com') + assert.equal(d.avvVersion, config.analyse.avvVersion) + const off = await adminPost('p6', { on: false }) + assert.deepEqual(off.body, { ok: true, state: 'off' }) +}) + +test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => { + await adminPost('p6', { on: true, avvConfirmedVerbally: true }) + const cur = await featureDoc('c1', 'p6') + await sf.disable(cur.$id, { by: 'Test' }) + puts.length = 0 + const r = await post(`${A}/deactivate`, { projectId: 'p6' }, { cookie }) + assert.deepEqual(r.body, { ok: true, state: 'disabled' }) + assert.equal((await featureDoc('c1', 'p6')).state, 'disabled') + const staff = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p6', by: 'Andrej' }) + assert.equal(staff.doc.state, 'off') +}) + +test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => { + await adminPost('p6', { on: true, avvConfirmedVerbally: true }) + const cur = await featureDoc('c1', 'p6') + puts.length = 0 + await sf.disable(cur.$id, { by: 'Test' }) + assert.equal(puts.length, 1) + assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false }) + // Ausfall des Sammeldienstes verhindert das Abschalten nicht + const real = an.analyseDeps.client.putSite + an.analyseDeps.client.putSite = async () => { throw new Error('weg') } + const again = await sf.disable(cur.$id, { by: 'Test' }) + an.analyseDeps.client.putSite = real + assert.equal(again.state, 'disabled') +})