diff --git a/public/admin.js b/public/admin.js index 5d118e6..b16f4f9 100644 --- a/public/admin.js +++ b/public/admin.js @@ -185,6 +185,81 @@ function renderInvoices(data) { ` } +const FEATURE_STATES = { + off: ['Aus', 'pill-muted'], + ordered: ['Bestellt', 'pill-amber'], + setup: ['Einrichtung', 'pill-warn'], + active: ['Aktiv', 'pill-ok'], + disabled: ['Abgeschaltet', 'pill-red'], +} + +function featureRowHtml(p) { + const n = p.features.news + const [label, cls] = FEATURE_STATES[n.state] || [n.state, 'pill-muted'] + const ticket = n.ticketMissing + ? 'Ticket fehlt' + : n.ticketWoid ? `#${escapeHtml(n.ticketWoid)}` : '–' + return ` + + ${escapeHtml(p.projectName || '–')} + ${escapeHtml(label)}${n.adminNote ? `
${escapeHtml(n.adminNote.slice(0, 60))}${n.adminNote.length > 60 ? '…' : ''}
` : ''} + ${ticket} + + ${n.state === 'active' ? '' : ''} + ${n.state === 'off' ? '' : n.state === 'disabled' ? '' : ''} + + ` +} + +function renderFeatures(projects) { + if (!projects.length) return '

Keine Projekte für diesen Kunden.

' + return ` + ${projects.map(featureRowHtml).join('')} +
ProjektNewsTicketAktionen

` +} + +function bindFeatureRows(box, customerId) { + const message = box.querySelector('.feature-message') + box.querySelectorAll('[data-feature-project]').forEach((row) => { + const cell = row.querySelector('.feature-actions') + const original = cell.innerHTML + const bindAsk = () => cell.querySelectorAll('[data-feature-ask]').forEach((btn) => + btn.addEventListener('click', () => askFeature(btn.dataset.featureAsk))) + const askFeature = (action) => { + const text = action === 'activate' + ? 'News ohne Zahlung aktivieren?' + : 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' + cell.innerHTML = `${text} + + + ` + cell.querySelector('[data-feature-no]').addEventListener('click', () => { + cell.innerHTML = original + bindAsk() + }) + cell.querySelector('[data-feature-yes]').addEventListener('click', async () => { + const note = cell.querySelector('[data-feature-note]').value.trim() + cell.querySelectorAll('button').forEach((b) => { b.disabled = true }) + try { + const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}/news`, { + method: 'POST', + body: JSON.stringify({ projectId: row.dataset.featureProject, action, note }), + }) + row.outerHTML = featureRowHtml(projects[0]) + bindFeatureRows(box, customerId) + box.querySelector('.feature-message').textContent = 'Gespeichert.' + } catch (err) { + cell.innerHTML = original + bindAsk() + message.textContent = err.message + message.className = 'feature-message error' + } + }) + } + bindAsk() + }) +} + async function toggleCustomerDetail(customerId, kind, cell) { const existing = document.getElementById(`detail-${customerId}`) if (existing) { @@ -206,6 +281,10 @@ async function toggleCustomerDetail(customerId, kind, cell) { if (kind === 'activity') { const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`) tr.innerHTML = `

Tickets & Arbeiten

${renderActivity(tickets)}` + } else if (kind === 'features') { + const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}`) + tr.innerHTML = `

Funktionen

${renderFeatures(projects)}` + bindFeatureRows(tr, customerId) } else { const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`) tr.innerHTML = `

Rechnungen

${renderInvoices(data)}` @@ -246,6 +325,7 @@ async function loadCustomers(search = '') { title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse` : ''} + @@ -258,6 +338,8 @@ async function loadCustomers(search = '') { btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs))) tbody.querySelectorAll('[data-activity]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity'))) + tbody.querySelectorAll('[data-features]').forEach((btn) => + btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.features, 'features'))) tbody.querySelectorAll('[data-invoices]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices'))) tbody.querySelectorAll('[data-offer-for]').forEach((btn) => diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js new file mode 100644 index 0000000..1baf8e0 --- /dev/null +++ b/server/routes/admin/funktionenAdmin.js @@ -0,0 +1,86 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { getDocument } from '../../services/appwriteAdmin.js' +import { withLocks } from '../../services/keyedLock.js' +import { adminActivate, assertOwnProject, disable, getFeatureDoc, listCustomerFeatures } from '../../services/siteFeatures.js' + +/** + * Funktionen (News-Pinnwand) je Kundenprojekt im Admin-Panel. + * Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/funktionen, dort hinter requireAdminSession. + */ +export const funktionenAdminRouter = Router() + +const NOTE_MAX = 300 +const adminName = (req) => req.session.adminName || req.session.name || 'Admin' + +function sendError(res, err) { + const status = err.status >= 400 && err.status < 600 ? err.status : 500 + if (status >= 500) console.error('[funktionen-admin]', err.message) + return res.status(status).json({ error: err.message || 'Interner Serverfehler' }) +} + +/** Neuester Eintrag zuerst; aeltere Eintraege fallen vorn heraus, sobald 300 Zeichen voll sind. */ +export function composeAdminNote(previous, { by, action, note }, now = new Date()) { + const entry = `${now.toISOString()} ${by}: ${action}${note ? ` – ${note}` : ''}`.slice(0, NOTE_MAX) + const older = String(previous || '') + return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry +} + +async function ticketExists(ticketId) { + if (!ticketId) return false + try { + await getDocument(config.collections.workorders, ticketId) + return true + } catch (err) { + if (err.status === 404) return false + throw err + } +} + +async function adminView(customerId) { + const projects = await listCustomerFeatures(customerId) + for (const p of projects) { + const news = p.features.news + const doc = await getFeatureDoc(customerId, p.projectId, 'news') + news.adminNote = doc?.adminNote || '' + news.ticketMissing = news.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) + } + return projects +} + +funktionenAdminRouter.get('/:customerId', async (req, res) => { + try { + res.json({ projects: await adminView(req.params.customerId) }) + } catch (err) { + sendError(res, err) + } +}) + +funktionenAdminRouter.post('/:customerId/news', async (req, res) => { + try { + const { customerId } = req.params + const { projectId, action } = req.body || {} + const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : '' + if (action !== 'activate' && action !== 'disable') { + return res.status(400).json({ error: 'Unbekannte Aktion' }) + } + if (typeof projectId !== 'string' || !projectId) { + return res.status(400).json({ error: 'Projekt fehlt' }) + } + await assertOwnProject(customerId, projectId) + const by = adminName(req) + await withLocks([`feature-admin:${projectId}`], async () => { + const doc = await getFeatureDoc(customerId, projectId, 'news') + const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) + if (action === 'activate') { + await adminActivate(customerId, projectId, 'news', { by, adminNote }) + } else { + if (!doc) throw Object.assign(new Error('Für dieses Projekt gibt es noch keine News-Pinnwand'), { status: 409 }) + await disable(doc.$id, { by, adminNote }) + } + }) + res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) }) + } catch (err) { + sendError(res, err) + } +}) diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index 0bdf1f9..9432fe7 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -26,6 +26,7 @@ import { storeChatUpload, uploadRateLimited, } from '../services/chatAttachments.js' +import { funktionenAdminRouter } from './admin/funktionenAdmin.js' import { offersAdminRouter } from './offers.js' import { storageTasksAdminRouter } from './storageTasks.js' import { takeoverAdminRouter } from './takeoverCredits.js' @@ -40,6 +41,8 @@ router.use('/takeover-credits', takeoverAdminRouter) router.use('/offers', offersAdminRouter) // Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher") router.use('/storage-tasks', storageTasksAdminRouter) +// Funktionen (News-Pinnwand) je Kundenprojekt aktivieren/abschalten (Kundenliste, Zeile "Funktionen") +router.use('/funktionen', funktionenAdminRouter) function norm(value) { return String(value || '').toLowerCase() diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index d12577b..6f2bf54 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -223,8 +223,8 @@ function byNote(by, verb) { return by ? `${verb} von ${by}`.slice(0, 300) : undefined } -export async function activate(docId, { by } = {}) { - const note = byNote(by, 'Freigeschaltet') +export async function activate(docId, { by, adminNote } = {}) { + const note = adminNote ?? byNote(by, 'Freigeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), activatedAt: new Date().toISOString(), @@ -232,8 +232,8 @@ export async function activate(docId, { by } = {}) { }) } -export async function disable(docId, { by } = {}) { - const note = byNote(by, 'Abgeschaltet') +export async function disable(docId, { by, adminNote } = {}) { + const note = adminNote ?? byNote(by, 'Abgeschaltet') return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('disabled'), disabledAt: new Date().toISOString(), @@ -242,11 +242,11 @@ export async function disable(docId, { by } = {}) { } /** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */ -export async function adminActivate(customerId, projectId, key, { by } = {}) { +export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) { assertKey(key) await assertOwnProject(customerId, projectId) const doc = await ensureFeatureDoc(customerId, projectId, key) - return activate(doc.$id, { by }) + return activate(doc.$id, { by, adminNote }) } /** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */ diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index 903eb58..223adee 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -12,15 +12,23 @@ const aw = await startAppwriteMock({ const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') -const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js') -const { customerCookie, seedFixtures } = await import('./support/fixtures.js') +const { startTestApp, get, rawGet, post, postWebhook } = await import('./support/testApp.js') +const { adminCookie, customerCookie, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: aboRoutes } = await import('../server/routes/abo.js') +const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js') +const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js') const sf = await import('../server/services/siteFeatures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') -const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes }) +const app = await startTestApp({ + '/api/funktionen': funktionenRoutes, + '/api/abo': aboRoutes, + '/api/portal-admin': portalAdminRoutes, + '/api/public/news': publicNewsRoutes, +}) const mails = mailSink() setMailTransportForTests(mails) after(() => { @@ -213,3 +221,81 @@ test('parallele_aktivierung_mailt_einmal', async () => { assert.equal(a.length + b.length, 1) assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1) }) + +// ---------------------------------------------------------------- Admin --- +const adminC = adminCookie() +const adminPost = (customerId, body, cookie = adminC) => post(`/api/portal-admin/funktionen/${customerId}/news`, body, { cookie }) + +test('admin_aktivieren_ohne_zahlung', async () => { + aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Admin-Test' }) + const wosBefore = workorders().length + const res = await adminPost('c1', { projectId: 'pa1', action: 'activate', note: 'Kulanz' }) + assert.equal(res.status, 200) + const view = res.body.projects.find((p) => p.projectId === 'pa1').features.news + assert.equal(view.state, 'active') + assert.equal(view.ticketWoid, null) + assert.match(view.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Kenso: activate – Kulanz$/) + const doc = await sf.getFeatureDoc('c1', 'pa1', 'news') + assert.equal(doc.enabled, true) + assert.equal(doc.unlockedByPurchase, false) + assert.equal(workorders().length, wosBefore) + const list = await get('/api/portal-admin/funktionen/c1', { cookie: adminC }) + assert.equal(list.projects.find((p) => p.projectId === 'pa1').features.news.state, 'active') +}) + +test('admin_abschalten', async () => { + const doc = await sf.getFeatureDoc('c1', 'pa1', 'news') + const publicUrl = `/api/public/news/${doc.siteKey}` + assert.equal((await rawGet(publicUrl)).status, 200) + const res = await adminPost('c1', { projectId: 'pa1', action: 'disable' }) + assert.equal(res.status, 200) + const view = res.body.projects[0].features.news + assert.equal(view.state, 'disabled') + assert.match(view.adminNote, /Kenso: disable \| .*Kenso: activate – Kulanz$/) + assert.ok(view.adminNote.length <= 300) + assert.equal((await sf.getFeatureDoc('c1', 'pa1', 'news')).enabled, false) + const { status, body } = await rawGet(publicUrl) + assert.ok(status === 404 || (status === 200 && (body.posts || []).length === 0), `öffentlich: ${status}`) +}) + +test('admin_ungueltig_und_fremd', async () => { + aw.seed('websiteProjects', { $id: 'pa4', customerId: 'c1', projectName: 'Ohne Dokument' }) + assert.equal((await adminPost('c1', { projectId: 'pa1', action: 'loeschen' })).status, 400) + assert.equal((await adminPost('c1', { projectId: 'p5', action: 'activate' })).status, 403) + assert.equal((await adminPost('c1', { projectId: 'gibtsnicht', action: 'activate' })).status, 403) + assert.equal((await adminPost('c1', { projectId: 'pa4', action: 'disable' })).status, 409) +}) + +test('admin_notiz_bleibt_im_limit', async () => { + aw.seed('websiteProjects', { $id: 'pa2', customerId: 'c1', projectName: 'Notiz' }) + for (let i = 0; i < 8; i += 1) { + await adminPost('c1', { projectId: 'pa2', action: i % 2 ? 'disable' : 'activate', note: 'x'.repeat(100) }) + } + const note = (await sf.getFeatureDoc('c1', 'pa2', 'news')).adminNote + assert.ok(note.length <= 300) + assert.match(note, /^\S+ Kenso: disable – x+/) +}) + +test('kunde_darf_admin_route_nicht', async () => { + aw.seed('websiteProjects', { $id: 'pa5', customerId: 'c1', projectName: 'Kundenversuch' }) + const res = await adminPost('c1', { projectId: 'pa5', action: 'activate' }, customerCookie('c1')) + assert.ok(res.status === 401 || res.status === 403) + assert.equal(await sf.getFeatureDoc('c1', 'pa5', 'news'), null) + const list = await rawGet('/api/portal-admin/funktionen/c1', { cookie: customerCookie('c1') }) + assert.ok(list.status === 401 || list.status === 403) +}) + +test('ticket_fehlt_markiert', async () => { + aw.seed('websiteProjects', { $id: 'pa3', customerId: 'c1', projectName: 'Ticketlos' }) + await checkout('pa3') + await postWebhook(event('checkout.session.completed', paidSession(sessions().at(-1).id, 'pa3'))) + const doc = await sf.getFeatureDoc('c1', 'pa3', 'news') + assert.equal(doc.state, 'setup') + const newsOf = async () => + (await get('/api/portal-admin/funktionen/c1', { cookie: adminC })).projects.find((p) => p.projectId === 'pa3').features.news + assert.equal((await newsOf()).ticketMissing, false) + await deleteDocument(config.collections.workorders, doc.ticketId) + const missing = await newsOf() + assert.equal(missing.ticketMissing, true) + assert.equal(missing.ticketWoid, null) +}) diff --git a/test/ui/admin-funktionen.ui.mjs b/test/ui/admin-funktionen.ui.mjs new file mode 100644 index 0000000..2e65960 --- /dev/null +++ b/test/ui/admin-funktionen.ui.mjs @@ -0,0 +1,95 @@ +// Playwright-Check admin_funktionen: statischer Server + JSON-Fixtures, nicht Teil von npm test. +// Aufruf: node test/ui/admin-funktionen.ui.mjs +import http from 'node:http' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' + +const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') +const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' } +const news = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: null, adminNote: '', ticketMissing: false, ...extra }) +const projects = [ + { projectId: 'p1', projectName: 'Bäckerei', features: { news: news('off') } }, + { projectId: 'p2', projectName: 'Café', features: { news: news('setup', { ticketMissing: true }) } }, + { projectId: 'p3', projectName: 'Kiosk', features: { news: news('active', { ticketWoid: '1234' }) } }, +] +const posts = [] + +const json = (res, status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x') + if (url.pathname === '/api/auth/me') return json(res, 200, { authenticated: true, role: 'admin', admin: { name: 'Kenso', email: 'k@w.de' } }) + if (url.pathname === '/api/portal-admin/customers') { + return json(res, 200, { customers: [{ id: 'c1', code: '1001', name: 'Test GmbH', email: 't@t.de', customerStatus: 'customer', portalAccessEnabled: true, projectCount: 3 }] }) + } + if (url.pathname === '/api/portal-admin/funktionen/c1') return json(res, 200, { projects }) + if (url.pathname === '/api/portal-admin/funktionen/c1/news' && req.method === 'POST') { + let raw = '' + for await (const chunk of req) raw += chunk + const body = JSON.parse(raw) + posts.push(body) + const p = projects.find((x) => x.projectId === body.projectId) + p.features.news = news(body.action === 'activate' ? 'active' : 'disabled', { adminNote: `2026-09-30T10:00:00.000Z Kenso: ${body.action}${body.note ? ` – ${body.note}` : ''}` }) + return json(res, 200, { projects: [p] }) + } + if (url.pathname.startsWith('/api/')) return json(res, 200, {}) + try { + const file = path.join(pub, url.pathname === '/' ? 'admin.html' : url.pathname) + res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }) + res.end(await readFile(file)) + } catch { + res.writeHead(404) + res.end() + } +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + +let failed = 0 +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) + if (!ok) failed += 1 +} + +const browser = await chromium.launch() +try { + const page = await browser.newPage() + const dialogs = [] + page.on('dialog', (d) => { dialogs.push(d.type()); d.dismiss() }) + page.on('pageerror', (e) => check('keine Seitenfehler', false, e.message)) + await page.goto(`http://127.0.0.1:${server.address().port}/admin.html`) + await page.click('.admin-tab[data-tab="customers"]') + await page.click('[data-features="c1"]') + await page.waitForSelector('[data-feature-project="p1"]') + const row = (id) => page.locator(`[data-feature-project="${id}"]`) + check('3 Zeilen', (await page.locator('[data-feature-project]').count()) === 3) + check('Ticket fehlt sichtbar', (await row('p2').innerText()).includes('Ticket fehlt')) + + // Aktivieren: erst Rückfrage in der Zeile, nichts gesendet + await row('p1').locator('[data-feature-ask="activate"]').click() + check('Rückfrage statt Sofort-Aktion', posts.length === 0 && (await row('p1').innerText()).includes('ohne Zahlung aktivieren?')) + await row('p1').locator('[data-feature-no]').click() + check('Abbrechen stellt Knöpfe wieder her', (await row('p1').locator('[data-feature-ask="activate"]').count()) === 1 && posts.length === 0) + await row('p1').locator('[data-feature-ask="activate"]').click() + await row('p1').locator('[data-feature-note]').fill('Kulanz') + await row('p1').locator('[data-feature-yes]').click() + await page.waitForFunction(() => document.querySelector('[data-feature-project="p1"]')?.innerText.includes('Aktiv')) + check('POST activate mit Notiz', JSON.stringify(posts[0]) === JSON.stringify({ projectId: 'p1', action: 'activate', note: 'Kulanz' }), JSON.stringify(posts[0])) + check('Zeile zeigt Aktiv und Notiz', (await row('p1').innerText()).includes('Kenso: activate – Kulanz')) + + // Abschalten + await row('p3').locator('[data-feature-ask="disable"]').click() + await row('p3').locator('[data-feature-yes]').click() + await page.waitForFunction(() => document.querySelector('[data-feature-project="p3"]')?.innerText.includes('Abgeschaltet')) + check('Abschalten aktualisiert Zeile', posts[1]?.action === 'disable') + check('keine Browser-Dialoge', dialogs.length === 0) + await page.screenshot({ path: '/tmp/claude-0/-root/a97f1ff7-2f71-45a7-bb9f-eaeacf7e64f1/scratchpad/admin-funktionen.png' }) +} finally { + await browser.close() + server.close() +} +console.log(failed ? `admin_funktionen: ${failed} FEHLER` : 'admin_funktionen: alle Prüfungen bestanden') +process.exit(failed ? 1 : 0)