From 9e8fd3371304e0819671bb7f69ef12597aaff827 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:08:41 +0000 Subject: [PATCH] Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht) Co-Authored-By: Claude Sonnet 5.5 --- server/middleware/kundenansichtGuard.js | 4 + server/routes/admin/funktionenAdmin.js | 41 +++-- server/routes/funktionen.js | 3 + server/services/customerAdmin.js | 9 ++ server/services/customerViewLog.js | 13 +- server/services/siteFeatures.js | 53 ++++-- server/services/termine/kontext.js | 10 +- test/customerViewLog.test.js | 7 +- test/funktionenRoutes.test.js | 3 + test/funktionenTermine.test.js | 207 ++++++++++++++++++++++++ test/kundenansichtGuard.test.js | 15 +- test/siteFeatures.test.js | 102 ++++++++++++ 12 files changed, 434 insertions(+), 33 deletions(-) create mode 100644 test/funktionenTermine.test.js diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 7f3e1fb..7b194d7 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -15,6 +15,10 @@ export const KUNDENANSICHT_ALLOWED = [ ['PATCH', /^\/api\/news\/[^/]+\/?$/], ['DELETE', /^\/api\/news\/[^/]+\/?$/], ['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/], + // Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, Abschnitt 9) + ['POST', /^\/api\/termine\/.+$/], + ['PATCH', /^\/api\/termine\/einstellungen\/?$/], + ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], ['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/], ['POST', /^\/api\/auth\/logout\/?$/], ['POST', /^\/api\/auth\/login\/?$/], diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index c6f5b7f..065145e 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -2,10 +2,19 @@ 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' +import { + FEATURE_KEYS, + FEATURE_LABELS, + adminActivate, + assertOwnProject, + disable, + getFeatureDoc, + listCustomerFeatureDocs, + listCustomerFeatures, +} from '../../services/siteFeatures.js' /** - * Funktionen (News-Pinnwand) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). + * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). * Eingehaengt in routes/team/index.js unter /api/team/funktionen, dort hinter requireStaffJwt. */ export const funktionenAdminRouter = Router() @@ -37,13 +46,17 @@ async function ticketExists(ticketId) { } } +// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet async function adminView(customerId) { - const projects = await listCustomerFeatures(customerId) + const docs = await listCustomerFeatureDocs(customerId) + const projects = await listCustomerFeatures(customerId, { docs }) 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))) + for (const key of FEATURE_KEYS) { + const view = p.features[key] + const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key) + view.adminNote = doc?.adminNote || '' + view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) + } } return projects } @@ -56,9 +69,11 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => { } }) -funktionenAdminRouter.post('/:customerId/news', async (req, res) => { +// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404 +funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { - const { customerId } = req.params + const { customerId, key } = req.params + if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' }) const { projectId, action } = req.body || {} const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : '' if (action !== 'activate' && action !== 'disable') { @@ -70,12 +85,14 @@ funktionenAdminRouter.post('/:customerId/news', async (req, res) => { await assertOwnProject(customerId, projectId) const by = adminName(req) await withLocks([`feature-admin:${projectId}`], async () => { - const doc = await getFeatureDoc(customerId, projectId, 'news') + const doc = await getFeatureDoc(customerId, projectId, key) const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { - await adminActivate(customerId, projectId, 'news', { by, adminNote }) + await adminActivate(customerId, projectId, key, { by, adminNote }) } else { - if (!doc) throw Object.assign(new Error('Für dieses Projekt gibt es noch keine News-Pinnwand'), { status: 409 }) + if (!doc) { + throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) + } await disable(doc.$id, { by, adminNote }) } }) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 7c8c6f8..604cd06 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -14,6 +14,7 @@ import { markOrdered, runActivation, } from '../services/siteFeatures.js' +import { termineFuerKunde } from '../services/termine/schalter.js' import { ELIGIBILITY_MESSAGES, @@ -104,6 +105,8 @@ router.get('/', requireSession, async (req, res) => { projects, setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, + // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel + termine: { verfuegbar: termineFuerKunde(req.session.email) }, }) } catch (err) { fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.') diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 2ff2053..e91a689 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -282,6 +282,15 @@ export async function deleteCustomerWithPortalAccess(customerId) { console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message) } + // Terminplaner abschalten: Gaeste koennen ihre Termine noch absagen, die Daten loescht der Tageslauf 30 Tage nach disabledAt. + // Fehler halten das Loeschen nicht auf (wie bei der Analyse). + try { + const { disableCustomerFeatures } = await import('./siteFeatures.js') + await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' }) + } catch (err) { + console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message) + } + const portalAccess = await getPortalAccessByCustomerId(customerId) if (portalAccess) { await deleteDocument( diff --git a/server/services/customerViewLog.js b/server/services/customerViewLog.js index 77b0549..9a8b2fe 100644 --- a/server/services/customerViewLog.js +++ b/server/services/customerViewLog.js @@ -5,7 +5,18 @@ import { createDocument, deleteDocument, listDocuments, Query } from './appwrite * Protokoll der Kundenansicht (Datenschutzerklaerung Abschnitt 6): wer wann das Portal eines Kunden geoeffnet * oder darin etwas geaendert hat. Schreiben nur ueber den Server-Schluessel; Eintraege leben 12 Monate. */ -export const VIEW_ACTIONS = ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'] +export const VIEW_ACTIONS = [ + 'geoeffnet', + 'beendet', + 'abgelaufen', + 'news_angelegt', + 'news_bild', + 'news_geaendert', + 'news_geloescht', + 'termin_einstellungen', + 'termin_aktion', + 'termin_manuell', +] const DETAIL_MAX = 300 const RETENTION_MS = 365 * 24 * 60 * 60 * 1000 diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 1e37aff..85350dd 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -6,7 +6,12 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { createFeatureTicket } from './ticketAdmin.js' -export const FEATURE_KEYS = ['news'] +export const FEATURE_KEYS = ['news', 'termine'] +/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */ +export const FEATURE_LABELS = { + news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, + termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, +} export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] export const DISABLED_MESSAGE = @@ -119,16 +124,19 @@ async function featureView(doc) { } } -export async function listCustomerFeatures(customerId) { +/** Alle Funktionsdokumente eines Kunden (alle Projekte, alle Funktionen). */ +export function listCustomerFeatureDocs(customerId) { + return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) +} + +/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */ +export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), ]) - const docs = await listDocuments(config.collections.portalFeatures, [ - Query.equal('customerId', customerId), - Query.limit(1000), - ]) + const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const result = [] for (const p of projects) { const features = {} @@ -287,6 +295,8 @@ export async function activate(docId, { by, adminNote } = {}) { return updateDocument(config.collections.portalFeatures, docId, { ...stateFields('active'), activatedAt: new Date().toISOString(), + // Eine wieder eingeschaltete Funktion darf nie in den 30-Tage-Loeschlauf des Terminplaners fallen (disabledAt-Marker) + disabledAt: null, ...(note ? { adminNote: note } : {}), }) } @@ -302,6 +312,30 @@ export async function disable(docId, { by, adminNote } = {}) { return doc } +/** + * Alle noch nicht abgeschalteten Dokumente einer Funktion eines Kunden abschalten (Kunde wird geloescht). Schon abgeschaltete + * behalten ihr disabledAt (Beginn der Loeschfrist), 'off' hat nichts zu sperren. Scheitert eines, werden die uebrigen trotzdem + * abgeschaltet und danach der erste Fehler weitergereicht. + * @returns {Promise} die abgeschalteten Dokumente + */ +export async function disableCustomerFeatures(customerId, key, { by, adminNote } = {}) { + assertKey(key) + const docs = (await listCustomerFeatureDocs(customerId)).filter( + (d) => d.featureKey === key && d.state !== 'off' && d.state !== 'disabled' + ) + const disabled = [] + let failure = null + for (const doc of docs) { + try { + disabled.push(await disable(doc.$id, { by, adminNote })) + } catch (err) { + failure ||= err + } + } + if (failure) throw failure + return disabled +} + // Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert. // Dynamischer Import: analyse.js importiert dieses Modul. async function stopAnalyseCollecting(doc) { @@ -352,11 +386,12 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul return activated } -export async function findActiveBySiteKey(siteKey) { - if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null +/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */ +export async function findActiveBySiteKey(siteKey, featureKey = 'news') { + if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('siteKey', siteKey), - Query.equal('featureKey', 'news'), + Query.equal('featureKey', featureKey), Query.equal('state', 'active'), Query.limit(1), ]) diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index 846864a..d861273 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -1,7 +1,8 @@ import { config } from '../../config.js' -import { Query, createDocument, getDocument, listDocuments, updateDocument } from '../appwriteAdmin.js' +import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js' import { analyseHosts } from '../analyse.js' import { withLocks } from '../keyedLock.js' +import { findActiveBySiteKey } from '../siteFeatures.js' import { termineAus } from './schalter.js' import { pruefeNow, pruefeText } from './util.js' @@ -95,12 +96,7 @@ export async function kontextPerSiteKey(siteKey, now = new Date()) { if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx const stand = generation - const [feature] = await listDocuments(config.collections.portalFeatures, [ - Query.equal('siteKey', siteKey), - Query.equal('featureKey', FUNKTION), - Query.equal('state', 'active'), - Query.limit(1), - ]) + const feature = await findActiveBySiteKey(siteKey, FUNKTION) let ctx = feature ? await baueKontext(feature) : null if (ctx && !ctx.einstellung) ctx = null if (stand === generation) merke(siteKey, ctx, nowMs) diff --git a/test/customerViewLog.test.js b/test/customerViewLog.test.js index e2865c7..008fa44 100644 --- a/test/customerViewLog.test.js +++ b/test/customerViewLog.test.js @@ -17,8 +17,11 @@ const entries = () => [...aw.docs('customerViewLog').values()] const seedLog = (id, customerId, createdAt, action = 'geoeffnet') => aw.seed('customerViewLog', { $id: id, customerId, staffUserId: 'u_staff2', staffName: 'Andrej', action, detail: '', createdAt }) -test('VIEW_ACTIONS: genau die sieben Aktionen', () => { - assert.deepEqual(VIEW_ACTIONS, ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht']) +test('VIEW_ACTIONS: genau die zehn Aktionen', () => { + assert.deepEqual(VIEW_ACTIONS, [ + 'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht', + 'termin_einstellungen', 'termin_aktion', 'termin_manuell', + ]) }) test('logViewAction schreibt alle Felder und kuerzt detail auf 300', async () => { diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index cb23ab0..8c44741 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -64,6 +64,9 @@ test('liste_zustaende', async () => { assert.equal(body.consentText, config.funktionen.consentText) assert.equal(body.projects.length, 4) for (const p of body.projects) assert.equal(p.features.news.state, 'off') + // Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js + for (const p of body.projects) assert.equal(p.features.termine.state, 'off') + assert.deepEqual(body.termine, { verfuegbar: false }) }) test('checkout_ohne_zustimmung_400', async () => { diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js new file mode 100644 index 0000000..54cb5b7 --- /dev/null +++ b/test/funktionenTermine.test.js @@ -0,0 +1,207 @@ +import { test, after } 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' + +// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter, +// Kunde loeschen schaltet den Terminplaner ab. News verhaelt sich wie bisher (test/funktionenRoutes.test.js). +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), 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, seedFixtures } = await import('./support/fixtures.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { default: teamRoutes } = await import('../server/routes/team/index.js') +const sf = await import('../server/services/siteFeatures.js') +const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedFixtures(aw) +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) +aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' }) + +const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } } +const cookie = customerCookie('c1') +const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts) +const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features +const workorders = () => aw.docs('workorders').size +const docOf = (projectId, key) => sf.getFeatureDoc('c1', projectId, key) + +test('Team aktiviert termine fuer ein Projekt, News bleibt unveraendert', async () => { + const tickets = workorders() + const res = await team('c1', 'termine', { projectId: 'pt1', action: 'activate', note: 'Pilot' }) + assert.equal(res.status, 200) + assert.deepEqual(res.body.projects.map((p) => p.projectId), ['pt1']) + const view = featuresOf(res, 'pt1') + assert.equal(view.termine.state, 'active') + assert.equal(view.termine.ticketWoid, null) + assert.match(view.termine.siteKey, /^[a-z2-7]{16}$/) + assert.match(view.termine.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/) + assert.equal(view.termine.ticketMissing, false) + assert.equal(view.news.state, 'off') + assert.equal(view.news.adminNote, '') + const doc = await docOf('pt1', 'termine') + assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedByPurchase], ['termine', 'active', true, false]) + assert.equal(await docOf('pt1', 'news'), null, 'kein News-Dokument entstanden') + assert.equal(workorders(), tickets, 'kein Ticket') + // das Projekt ohne Aktion bleibt aus + const list = await get('/api/team/funktionen/c1', STAFF) + assert.equal(list.projects.find((p) => p.projectId === 'pt2').features.termine.state, 'off') +}) + +test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => { + const res = await team('c1', 'news', { projectId: 'pt1', action: 'activate', note: 'Kulanz' }) + assert.equal(res.status, 200) + const view = featuresOf(res, 'pt1') + assert.equal(view.news.state, 'active') + assert.match(view.news.adminNote, /Andrej: activate – Kulanz$/) + assert.equal(view.termine.state, 'active') + assert.doesNotMatch(view.termine.adminNote, /Kulanz/) + assert.notEqual((await docOf('pt1', 'news')).siteKey, (await docOf('pt1', 'termine')).siteKey) + assert.equal((await team('c1', 'news', { projectId: 'pt1', action: 'disable' })).status, 200) + const after = await docOf('pt1', 'termine') + assert.equal(after.state, 'active') +}) + +test('Abschalten setzt disabledAt, erneutes Aktivieren leert es (R36)', async () => { + const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' }) + assert.equal(off.status, 200) + assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled') + const doc = await docOf('pt1', 'termine') + assert.equal(doc.enabled, false) + assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000) + const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' }) + assert.equal(featuresOf(on, 'pt1').termine.state, 'active') + assert.equal((await docOf('pt1', 'termine')).disabledAt, null) +}) + +test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => { + const docs = aw.docs('portalFeatures').size + for (const key of ['kalender', 'analyse', 'Termine', 'termine2']) { + const res = await team('c1', key, { projectId: 'pt2', action: 'activate' }) + assert.equal(res.status, 404, key) + assert.equal(typeof res.body.error, 'string', key) + } + // der Schluessel wird vor dem Rumpf geprueft + assert.equal((await team('c1', 'kalender', {})).status, 404) + assert.equal(aw.docs('portalFeatures').size, docs) + assert.equal(await docOf('pt2', 'termine'), null) +}) + +test('Meldungen sind je Funktion grammatisch', async () => { + aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Ohne Dokument' }) + const termine = await team('c1', 'termine', { projectId: 'pt4', action: 'disable' }) + assert.equal(termine.status, 409) + assert.equal(termine.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + const news = await team('c1', 'news', { projectId: 'pt4', action: 'disable' }) + assert.equal(news.status, 409) + assert.equal(news.body.error, 'Für dieses Projekt gibt es noch keine News-Pinnwand') + assert.equal(await docOf('pt4', 'termine'), null) +}) + +test('Pruefungen wie bei News: Aktion, Projekt, fremdes Projekt, Kunde', async () => { + assert.equal((await team('c1', 'termine', { projectId: 'pt1', action: 'loeschen' })).status, 400) + assert.equal((await team('c1', 'termine', { action: 'activate' })).status, 400) + assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'activate' })).status, 403) + assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'activate' })).status, 403) + assert.equal(await sf.getFeatureDoc('c2', 'pt3', 'termine'), null) + const asCustomer = await team('c1', 'termine', { projectId: 'pt2', action: 'activate' }, { cookie }) + assert.ok(asCustomer.status === 401 || asCustomer.status === 403) + assert.equal(await docOf('pt2', 'termine'), null) +}) + +test('Die Ansicht des Teams liest die Funktionsdokumente des Kunden einmal', async (t) => { + const echt = globalThis.fetch + const abfragen = [] + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`) + return echt(url, optionen) + } + t.after(() => (globalThis.fetch = echt)) + const list = await get('/api/team/funktionen/c1', STAFF) + assert.ok(list.projects.length >= 3) + assert.equal(abfragen.length, 1, abfragen.join(' | ')) + assert.match(abfragen[0], /^GET .*\/collections\/portalFeatures\/documents$/) +}) + +// ------------------------------------------------------------- Kundenliste --- +const MODUS = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] } +const termineMit = (mode, pilotEmails = []) => { + config.termine.mode = mode + config.termine.pilotEmails = pilotEmails +} +after(() => termineMit(MODUS.mode, MODUS.pilotEmails)) +const verfuegbar = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine + +test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail', async () => { + termineMit('off') + assert.deepEqual(await verfuegbar(), { verfuegbar: false }) + termineMit('pilot', ['k@example.invalid']) + assert.deepEqual(await verfuegbar(), { verfuegbar: true }) + assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste') + termineMit('pilot', []) + assert.deepEqual(await verfuegbar(), { verfuegbar: false }) + termineMit('pilot', ['*']) + assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true }) + termineMit('on') + assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true }) +}) + +test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => { + termineMit('pilot', ['*']) + const body = await get('/api/funktionen', { cookie }) + assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen') + const pt1 = body.projects.find((p) => p.projectId === 'pt1') + assert.equal(pt1.features.termine.state, 'active') + assert.equal(pt1.features.news.state, 'disabled') + const pt2 = body.projects.find((p) => p.projectId === 'pt2') + assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off']) + assert.deepEqual(Object.keys(pt2.features), ['news', 'termine']) + // ohne Sitzung wie bisher gesperrt + assert.equal((await rawGet('/api/funktionen')).status, 401) +}) + +// --------------------------------------------------------- Kunde loeschen --- +test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht), nur seine', async () => { + aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' }) + aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' }) + aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' }) + aw.seed('websiteProjects', { $id: 'ktp3', customerId: 'kt1', projectName: 'Weg Drei' }) + await sf.adminActivate('kt1', 'ktp1', 'termine', { by: 'test' }) + await sf.adminActivate('kt1', 'ktp1', 'news', { by: 'test' }) + await sf.adminActivate('kt1', 'ktp2', 'termine', { by: 'test' }) + const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' }) + const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt + const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine') + + await deleteCustomerWithPortalAccess('kt1') + + assert.equal(aw.docs('customers').has('kt1'), false) + for (const projekt of ['ktp1', 'ktp2']) { + const doc = await sf.getFeatureDoc('kt1', projekt, 'termine') + assert.deepEqual([doc.state, doc.enabled, doc.adminNote], ['disabled', false, 'Kunde gelöscht'], projekt) + assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt) + } + assert.equal((await sf.getFeatureDoc('kt1', 'ktp3', 'termine')).disabledAt, frueher, 'Beginn der 30 Tage bleibt') + assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners') + assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde') +}) + +test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + aw.seed('customers', { $id: 'kt2', name: 'Weg Zwei', email: 'weg2@example.invalid' }) + aw.seed('websiteProjects', { $id: 'ktp4', customerId: 'kt2', projectName: 'Weg Vier' }) + await sf.adminActivate('kt2', 'ktp4', 'termine', { by: 'test' }) + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + await deleteCustomerWithPortalAccess('kt2') + assert.equal(aw.docs('customers').has('kt2'), false) + assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0])))) +}) diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 74f8fee..c827ca0 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -102,14 +102,25 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`) }) -test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => { +test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => { const ok = [ ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], ['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'], ['POST', '/api/auth/logout'], ['POST', '/api/auth/login'], + ['POST', '/api/termine/buchungen'], ['POST', '/api/termine/buchungen/abc/aktion'], ['POST', '/api/termine/ressourcen/abc/feed'], + ['PATCH', '/api/termine/einstellungen'], ['PATCH', '/api/termine/einstellungen/'], + ['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'], ] for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`) - for (const [m, p] of [['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout']]) { + assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 3, 'genau drei Termine-Eintraege dazu') + for (const [m, p] of [ + ['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'], + // Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen + ['POST', '/api/termineX'], ['POST', '/api/termineX/buchungen'], ['POST', '/api/termine'], ['POST', '/api/termine/'], + ['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'], + ['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'], + ['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], + ]) { assert.ok(!allowed(m, p), `${m} ${p}`) } }) diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index b4f43e7..818cb9a 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -162,3 +162,105 @@ test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => { assert.equal(tickets().length, ticketsBefore) } }) + +// ---------------------------------------------------------------- Terminplaner als zweite Funktion --- +test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => { + // erst hier angelegt: die aelteren Tests oben zaehlen die Projekte von c1 + aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) + aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' }) + aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' }) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_LABELS, { + news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, + termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + }) +}) + +test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => { + const list = await sf.listCustomerFeatures('c1') + const p = list.find((x) => x.projectId === 'pt1') + assert.deepEqual(Object.keys(p.features), ['news', 'termine']) + assert.equal(p.features.termine.state, 'off') + assert.equal(p.features.termine.siteKey, null) + assert.equal(p.features.termine.ticketWoid, null) +}) + +test('termine_wird_aktiviert_und_bekommt_eigenen_sitekey_und_eigenes_dokument', async () => { + const doc = await sf.adminActivate('c1', 'pt1', 'termine', { by: 'kenso' }) + assert.equal(doc.featureKey, 'termine') + assert.equal(doc.state, 'active') + assert.equal(doc.enabled, true) + assert.equal(doc.unlockedByPurchase, false) + assert.match(doc.siteKey, /^[a-z2-7]{16}$/) + assert.equal(await sf.getFeatureDoc('c1', 'pt1', 'news'), null, 'News bleibt unberuehrt') + const view = (await sf.listCustomerFeatures('c1')).find((x) => x.projectId === 'pt1').features + assert.equal(view.termine.state, 'active') + assert.equal(view.termine.siteKey, doc.siteKey) + assert.equal(view.news.state, 'off') + await assert.rejects(() => sf.adminActivate('c1', 'pt1', 'kalender', {}), { status: 400 }) +}) + +test('findActiveBySiteKey: Vorgabe ist news, termine nur mit zweitem Argument, nie der andere Schluessel', async () => { + const termine = await sf.getFeatureDoc('c1', 'pt1', 'termine') + assert.equal((await sf.findActiveBySiteKey(termine.siteKey, 'termine')).$id, termine.$id) + assert.equal(await sf.findActiveBySiteKey(termine.siteKey), null, 'ohne zweites Argument = news') + assert.equal(await sf.findActiveBySiteKey(termine.siteKey, 'news'), null) + const news = await sf.adminActivate('c1', 'pt2', 'news', { by: 'kenso' }) + assert.equal((await sf.findActiveBySiteKey(news.siteKey)).$id, news.$id) + assert.equal((await sf.findActiveBySiteKey(news.siteKey, 'news')).$id, news.$id) + assert.equal(await sf.findActiveBySiteKey(news.siteKey, 'termine'), null) + assert.equal(await sf.findActiveBySiteKey('zzzzzzzzzzzzzzzz', 'termine'), null) + assert.equal(await sf.findActiveBySiteKey('KURZ', 'termine'), null) +}) + +test('abgeschaltet_ist_nicht_auffindbar_und_wieder_aktivieren_leert_disabledAt (R36)', async () => { + const doc = await sf.getFeatureDoc('c1', 'pt1', 'termine') + const off = await sf.disable(doc.$id, { by: 'kenso' }) + assert.equal(off.state, 'disabled') + assert.ok(Math.abs(Date.parse(off.disabledAt) - Date.now()) < 5000) + assert.equal(await sf.findActiveBySiteKey(doc.siteKey, 'termine'), null) + const on = await sf.activate(doc.$id, { by: 'kenso' }) + assert.equal(on.state, 'active') + assert.equal(on.disabledAt, null, 'sonst koennte der 30-Tage-Loeschlauf eine wieder eingeschaltete Funktion treffen') + assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, null) + assert.equal((await sf.findActiveBySiteKey(doc.siteKey, 'termine')).$id, doc.$id) +}) + +test('activate gilt fuer jede Funktion gleich: News verliert disabledAt ebenfalls', async () => { + const news = await sf.getFeatureDoc('c1', 'pt2', 'news') + assert.equal((await sf.disable(news.$id, {})).state, 'disabled') + assert.equal((await sf.activate(news.$id, {})).disabledAt, null) +}) + +test('activateClosedTickets bleibt bei News (TP2): ein termine-Dokument im Zustand setup wird nicht angefasst', async () => { + aw.seed('portalFeatures', { $id: 'f_setup_termine', customerId: 'c1', projectId: 'pt2', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'bbbbbbbbbbbbbbbb', ticketId: 't_closed' }) + aw.seed('workorders', { $id: 't_closed', woid: 'WO-9', status: 'Closed' }) + const done = await sf.activateClosedTickets({ customerId: 'c1' }) + assert.deepEqual(done.map((d) => d.featureKey), []) + assert.equal(aw.docs('portalFeatures').get('f_setup_termine').state, 'setup') + const { deleteDocument } = await import('../server/services/appwriteAdmin.js') + const { config } = await import('../server/config.js') + await deleteDocument(config.collections.portalFeatures, 'f_setup_termine') +}) + +test('disableCustomerFeatures: schaltet nur die genannte Funktion des Kunden ab, mit Notiz', async () => { + const mine = await sf.getFeatureDoc('c1', 'pt1', 'termine') + const other = await sf.adminActivate('c2', 'pt3', 'termine', { by: 'kenso' }) + const news = await sf.getFeatureDoc('c1', 'pt2', 'news') + await sf.activate(news.$id, {}) + const done = await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' }) + assert.deepEqual(done.map((d) => d.$id), [mine.$id]) + const after = await sf.getFeatureDoc('c1', 'pt1', 'termine') + assert.equal(after.state, 'disabled') + assert.equal(after.enabled, false) + assert.equal(after.adminNote, 'Kunde gelöscht') + assert.ok(after.disabledAt) + assert.equal((await sf.getFeatureDoc('c2', 'pt3', 'termine')).state, 'active', 'anderer Kunde') + assert.equal(other.state, 'active') + assert.equal((await sf.getFeatureDoc('c1', 'pt2', 'news')).state, 'active', 'andere Funktion') + // schon abgeschaltet: disabledAt (Beginn der 30 Tage) bleibt + const marker = after.disabledAt + assert.deepEqual(await sf.disableCustomerFeatures('c1', 'termine', { adminNote: 'Kunde gelöscht' }), []) + assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).disabledAt, marker) + await assert.rejects(() => sf.disableCustomerFeatures('c1', 'kalender', {}), { status: 400 }) +})