Admin: News je Projekt aktivieren und abschalten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a421308019
commit
6dac6aa073
6 files changed
+361
-9
No files matched your search
@@ -185,6 +185,81 @@ function renderInvoices(data) {
|
||||
</tbody></table>`
|
||||
}
|
||||
|
||||
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
|
||||
? '<span class="pill pill-red">Ticket fehlt</span>'
|
||||
: n.ticketWoid ? `#${escapeHtml(n.ticketWoid)}` : '<span class="muted">–</span>'
|
||||
return `
|
||||
<tr data-feature-project="${escapeHtml(p.projectId)}">
|
||||
<td>${escapeHtml(p.projectName || '–')}</td>
|
||||
<td><span class="pill ${cls}">${escapeHtml(label)}</span>${n.adminNote ? `<div class="muted" title="${escapeHtml(n.adminNote)}">${escapeHtml(n.adminNote.slice(0, 60))}${n.adminNote.length > 60 ? '…' : ''}</div>` : ''}</td>
|
||||
<td>${ticket}</td>
|
||||
<td class="feature-actions">
|
||||
${n.state === 'active' ? '' : '<button type="button" class="mini-btn primary" data-feature-ask="activate">Aktivieren (ohne Zahlung)</button>'}
|
||||
${n.state === 'off' ? '' : n.state === 'disabled' ? '' : '<button type="button" class="mini-btn" data-feature-ask="disable">Abschalten</button>'}
|
||||
</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
function renderFeatures(projects) {
|
||||
if (!projects.length) return '<p class="muted">Keine Projekte für diesen Kunden.</p>'
|
||||
return `<table class="admin-table"><thead><tr><th>Projekt</th><th>News</th><th>Ticket</th><th>Aktionen</th></tr></thead><tbody>
|
||||
${projects.map(featureRowHtml).join('')}
|
||||
</tbody></table><p class="feature-message muted" role="status"></p>`
|
||||
}
|
||||
|
||||
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 = `<span>${text}</span>
|
||||
<input class="code-input" data-feature-note placeholder="Notiz (optional)" maxlength="120">
|
||||
<button type="button" class="mini-btn primary" data-feature-yes>Ja</button>
|
||||
<button type="button" class="mini-btn" data-feature-no>Abbrechen</button>`
|
||||
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 = `<td colspan="7"><h4>Tickets & Arbeiten</h4>${renderActivity(tickets)}</td>`
|
||||
} else if (kind === 'features') {
|
||||
const { projects } = await api(`/api/portal-admin/funktionen/${encodeURIComponent(customerId)}`)
|
||||
tr.innerHTML = `<td colspan="7"><h4>Funktionen</h4>${renderFeatures(projects)}</td>`
|
||||
bindFeatureRows(tr, customerId)
|
||||
} else {
|
||||
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
|
||||
tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>`
|
||||
@@ -246,6 +325,7 @@ async function loadCustomers(search = '') {
|
||||
title="Bedarf im Gespräch aufnehmen und Preis kalkulieren">Rechnungsanalyse</a>` : ''}
|
||||
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
|
||||
<button class="mini-btn" data-features="${escapeHtml(c.id)}">Funktionen</button>
|
||||
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
|
||||
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
|
||||
</div>
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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'. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user