diff --git a/server/index.js b/server/index.js index 270c1b4..58187be 100644 --- a/server/index.js +++ b/server/index.js @@ -20,6 +20,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' +import { runAboCheck } from './services/analyse.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -166,6 +167,16 @@ const server = app.listen(config.port, '0.0.0.0', () => { .catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message)) setInterval(activateFeatures, 5 * 60 * 1000).unref() + // Website-Analyse: aktive Analysen ohne laufendes Wartungs-Abo beenden (taeglich, 2 Minuten nach Start beginnend) + if (config.analyse.enabled) { + const analyseAboCheck = () => + runAboCheck({ now: new Date() }) + .then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`)) + .catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message)) + setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() + setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() + } + // Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten) const repoAssign = () => runRepoAssignJob({ now: new Date() }).catch((err) => diff --git a/server/mail-templates/K18 Analyse aktiv.html b/server/mail-templates/K18 Analyse aktiv.html new file mode 100644 index 0000000..2f85321 --- /dev/null +++ b/server/mail-templates/K18 Analyse aktiv.html @@ -0,0 +1,48 @@ + + + + + + + + +Deine Website-Analyse ist aktiv – {{projekt}} + + + + +Deine Website-Analyse ist eingeschaltet. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K19 Analyse beendet.html b/server/mail-templates/K19 Analyse beendet.html new file mode 100644 index 0000000..98c4792 --- /dev/null +++ b/server/mail-templates/K19 Analyse beendet.html @@ -0,0 +1,48 @@ + + + + + + + + +Deine Website-Analyse wurde beendet – {{projekt}} + + + + +Deine Website-Analyse wurde beendet. ͏ ͏ ͏ + + + + + diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 2783b0b..3f135f5 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -6,9 +6,8 @@ import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' -import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js' +import { triggerRebuild, previewBranchOf } from '../../services/previewRebuild.js' import { withLocks } from '../../services/keyedLock.js' -import { getRepoInfo } from '../../services/giteaAdmin.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() @@ -64,29 +63,6 @@ async function view(doc) { } } -async function previewBranchOf(doc) { - return doc.previewBranch || (await getRepoInfo(doc.repoFullName).catch(() => null))?.defaultBranch || 'main' -} - -// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts) -async function triggerRebuild(doc) { - const repo = doc.repoFullName - const branch = await previewBranchOf(doc) - const found = await fetchPreviewConfig(repo, branch).catch(() => null) - const cfg = { - enabled: true, - type: 'static', - displayName: doc.projectName, - ...(found || {}), - branch, - subdomain: doc.subdomain, - } - const result = await runDeploy(repo, branch, cfg, { project: doc }) - // runDeploy meldet Runner-Fehler im Ergebnis; Schutzschalter (503) an WOMS durchreichen - if (result?.error && /Server ausgelastet/.test(result.error)) throw fail(503, BUSY) - return result -} - const wrap = (fn) => async (req, res) => { try { await fn(req, res) diff --git a/server/services/analyse.js b/server/services/analyse.js new file mode 100644 index 0000000..5e2f5e0 --- /dev/null +++ b/server/services/analyse.js @@ -0,0 +1,212 @@ +// Website-Analyse: ein-/ausschalten je Projekt, Wartungs-Abo-Pruefung, Datenschutz-Ticket, Kundenmails +import { config } from '../config.js' +import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { analyticsClient } from './analyticsClient.js' +import { withLocks } from './keyedLock.js' +import { sendCustomerMail } from './mailer.js' +import { triggerRebuild } from './previewRebuild.js' +import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js' +import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' +import { createFeatureTicket } from './ticketAdmin.js' + +const FEATURE_KEY = 'analyse' +const SITE_KEY_RE = /^[a-z0-9]{16,40}$/ +const COLOR_RE = /^#[0-9a-f]{6}$/ +const DEFAULT_COLOR = '#111111' + +/** Austauschbar fuer Tests: Sammeldienst-Client und Neubau. */ +export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild } + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +export const ELIGIBILITY_MESSAGES = { + nicht_dein_projekt: 'Kein Zugriff auf dieses Projekt', + kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.', + nicht_oeffentlich: 'Die Website-Analyse gibt es nur für öffentliche Websites.', + keine_subdomain: 'Für dieses Projekt ist noch keine Subdomain eingerichtet.', +} +const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 } + +/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status. */ +async function hasWartungsAbo(customerId) { + const sub = await getCurrentSubscription(customerId) + return Boolean(sub && sub.productKey === 'wartung' && ACTIVE_STATUSES.has(sub.status)) +} + +async function loadOwnProject(customerId, projectId) { + try { + return await assertOwnProject(customerId, projectId) + } catch (err) { + if (err.status === 403) return null + throw err + } +} + +/** @returns {Promise<{ ok: boolean, reason?: string, project?: object }>} */ +export async function analyseEligibility(customerId, projectId) { + const project = await loadOwnProject(customerId, projectId) + if (!project) return { ok: false, reason: 'nicht_dein_projekt' } + if (!(await hasWartungsAbo(customerId))) return { ok: false, reason: 'kein_abo' } + if (project.isPublic !== true) return { ok: false, reason: 'nicht_oeffentlich' } + if (!project.subdomain) return { ok: false, reason: 'keine_subdomain' } + return { ok: true, project } +} + +/** `.project.webklar.com` plus Live-Domain (klein, ohne Schema/Pfad), ohne Doppelte. */ +export function analyseDomains(project) { + const domains = [] + if (project.subdomain) domains.push(`${String(project.subdomain).trim().toLowerCase()}.project.webklar.com`) + const live = String(project.liveDomain || '') + .trim() + .toLowerCase() + .replace(/^[a-z]+:\/\//, '') + .replace(/[/?#].*$/, '') + if (live) domains.push(live) + return [...new Set(domains)] +} + +async function customerOf(customerId) { + try { + return await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +async function rebuildSafe(project) { + if (!project.repoFullName || project.previewMode === 'off') return + try { + await analyseDeps.rebuild(project) + } catch (err) { + // Zustand bleibt gespeichert, der naechste Neubau holt nach + console.warn(`[analyse] Neubau für Projekt ${project.$id} fehlgeschlagen:`, err.message) + } +} + +async function mailSafe(customerId, template, project) { + try { + const customer = await customerOf(customerId) + if (!customer?.email) return + await sendCustomerMail(customer.email, { + template, + values: { + kunde_name: customer.name || '', + projekt: project.projectName || project.subdomain || '', + portal_url: `${config.publicBaseUrl}/dashboard.html#news`, + }, + }) + } catch (err) { + console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message) + } +} + +function privacyTicket(customerId, project, siteKey) { + const name = project.projectName || project.subdomain || '' + const domains = analyseDomains(project) + return createFeatureTicket({ + customerId, + project, + featureKey: FEATURE_KEY, + siteKey, + topic: `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`, + details: [ + `Funktion: ${FEATURE_KEY}`, + `Projekt: ${name}`, + `Domain(s): ${domains.join(', ')}`, + `Zusatzseite: https://${domains[domains.length - 1]}/datenschutz-analyse.html`, + 'Satz + Link in die bestehende Datenschutzerklärung einfügen', + ].join('\n'), + }) +} + +export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) { + if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) { + throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen') + } + const color = accentColor === undefined || accentColor === null || accentColor === '' ? DEFAULT_COLOR : String(accentColor).toLowerCase() + if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe') + + return withLocks([`analyse:${projectId}`], async () => { + const eligibility = await analyseEligibility(customerId, projectId) + if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], ELIGIBILITY_MESSAGES[eligibility.reason]) + const { project } = eligibility + + const existing = await ensureFeatureDoc(customerId, projectId, FEATURE_KEY) + if (existing.state === 'active') { + // Schon aktiv: nur die Farbe (Banner) aktualisieren, kein zweites Ticket, keine zweite Mail + if (existing.accentColor === color) return { doc: existing, changed: false } + const doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color }) + await rebuildSafe(project) + return { doc, changed: true } + } + + const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey() + // Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar) + await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true }) + const now = new Date().toISOString() + let doc = await updateDocument(config.collections.portalFeatures, existing.$id, { + ...stateFields('active'), + siteKey, + accentColor: color, + avvVersion: consent.avvVersion, + avvAcceptedAt: now, + avvAcceptedBy: String(by || 'customer').slice(0, 100), + activatedAt: now, + offSince: '', + }) + if (!doc.privacyTicketId) { + const { ticketId } = await privacyTicket(customerId, project, siteKey) + doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId }) + } + await rebuildSafe(project) + await mailSafe(customerId, 'K18', project) + return { doc, changed: true } + }) +} + +export async function deactivateAnalyse({ customerId, projectId, now = new Date() }) { + 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 } + if (existing.siteKey) { + await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false }) + } + const doc = await updateDocument(config.collections.portalFeatures, existing.$id, { + ...stateFields('off'), + offSince: now.toISOString(), + }) + await rebuildSafe(project) + return { doc, changed: true } + }) +} + +/** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */ +export async function runAboCheck({ now = new Date() } = {}) { + const docs = await listDocuments(config.collections.portalFeatures, [ + Query.equal('featureKey', FEATURE_KEY), + Query.equal('state', 'active'), + Query.limit(1000), + ]) + const deactivated = [] + for (const doc of docs) { + try { + // Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse + if (await hasWartungsAbo(doc.customerId)) continue + const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now }) + if (!changed) continue + deactivated.push(doc.$id) + const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({})) + await mailSafe(doc.customerId, 'K19', project) + } catch (err) { + console.error(`[analyse] Abo-Prüfung für Projekt ${doc.projectId} fehlgeschlagen:`, err.message) + } + } + return { checked: docs.length, deactivated } +} diff --git a/server/services/previewRebuild.js b/server/services/previewRebuild.js new file mode 100644 index 0000000..a7e4305 --- /dev/null +++ b/server/services/previewRebuild.js @@ -0,0 +1,33 @@ +// Neubau einer Preview/Live-Seite anstossen (WOMS-Preview-Block, Website-Analyse ein/aus) +import { getRepoInfo } from './giteaAdmin.js' +import { runDeploy, fetchPreviewConfig } from './previewDeploy.js' + +const BUSY = 'Server ausgelastet, versuche es gleich noch mal' + +export async function previewBranchOf(doc) { + return doc.previewBranch || (await getRepoInfo(doc.repoFullName).catch(() => null))?.defaultBranch || 'main' +} + +// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts) +export async function triggerRebuild(doc) { + const repo = doc.repoFullName + const branch = await previewBranchOf(doc) + const found = await fetchPreviewConfig(repo, branch).catch(() => null) + const cfg = { + enabled: true, + type: 'static', + displayName: doc.projectName, + ...(found || {}), + branch, + subdomain: doc.subdomain, + } + const result = await runDeploy(repo, branch, cfg, { project: doc }) + // runDeploy meldet Runner-Fehler im Ergebnis; Schutzschalter (503) an WOMS durchreichen + if (result?.error && /Server ausgelastet/.test(result.error)) { + const err = new Error(BUSY) + err.status = 503 + err.expected = true + throw err + } + return result +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 9302e90..45fb2b5 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -45,7 +45,7 @@ function assertKey(key) { } /** state und enabled werden immer zusammen geschrieben. */ -function stateFields(state) { +export function stateFields(state) { return { state, enabled: state === 'active' } } @@ -71,7 +71,7 @@ export async function assertOwnProject(customerId, projectId) { } /** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */ -async function ensureFeatureDoc(customerId, projectId, key) { +export async function ensureFeatureDoc(customerId, projectId, key) { const existing = await getFeatureDoc(customerId, projectId, key) if (existing) return existing try { @@ -310,7 +310,7 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote /** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */ export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) { - const queries = [Query.equal('state', 'setup'), Query.limit(1000)] + const queries = [Query.equal('featureKey', 'news'), Query.equal('state', 'setup'), Query.limit(1000)] if (customerId) queries.push(Query.equal('customerId', customerId)) const pending = await listDocuments(config.collections.portalFeatures, queries) const activated = [] @@ -343,6 +343,7 @@ export async function findActiveBySiteKey(siteKey) { if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null const [doc] = await listDocuments(config.collections.portalFeatures, [ Query.equal('siteKey', siteKey), + Query.equal('featureKey', 'news'), Query.equal('state', 'active'), Query.limit(1), ]) diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js index dbfe03c..866452b 100644 --- a/server/services/ticketAdmin.js +++ b/server/services/ticketAdmin.js @@ -72,7 +72,7 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) { * Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite. * Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei. */ -export async function createFeatureTicket({ customerId, project, featureKey, siteKey }) { +export async function createFeatureTicket({ customerId, project, featureKey, siteKey, topic = null, details = null }) { const customer = await getDocument(config.collections.customers, customerId).catch(() => null) const woid = await generateNextWoid() const projectName = project?.projectName || project?.subdomain || '' @@ -82,7 +82,7 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit const ticket = await createDocument( config.collections.workorders, { - topic: `News-Pinnwand einbauen – ${projectName}`.trim(), + topic: topic || `News-Pinnwand einbauen – ${projectName}`.trim(), type: 'Webpage', status: 'Open', priority: 1, @@ -92,7 +92,7 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit customerName: customer?.name || '', customerLocation: customer?.location || '', woid, - details: [ + details: details || [ `Funktion: ${featureKey}`, `Projekt: ${projectName}`, `Domain: ${domain}`, diff --git a/test/analyse.test.js b/test/analyse.test.js new file mode 100644 index 0000000..b0cd60e --- /dev/null +++ b/test/analyse.test.js @@ -0,0 +1,177 @@ +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' + +// Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest +const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId'] +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 { wartungDoc, hosting2Doc } = await import('./support/fixtures.js') +const an = await import('../server/services/analyse.js') +const sf = await import('../server/services/siteFeatures.js') +const { updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([aw.close(), st.close()]) +}) + +const puts = [] +const rebuilds = [] +an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) } +an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id) + +const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion }) +const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse')) +const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse') + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' }) +aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' }) +aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' }) +aw.seed('subscriptions', wartungDoc) +aw.seed('subscriptions', hosting2Doc) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' }) +aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false }) +aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true }) +aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true }) +aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true }) + +beforeEach(() => { + puts.length = 0 + rebuilds.length = 0 + mails.sent.length = 0 +}) + +test('Berechtigung: Wartungs-Abo, öffentlich, Subdomain, Eigentum', async () => { + assert.equal((await an.analyseEligibility('c1', 'p1')).ok, true) + assert.deepEqual(await an.analyseEligibility('c1', 'p2'), { ok: false, reason: 'nicht_oeffentlich' }) + assert.deepEqual(await an.analyseEligibility('c1', 'p3'), { ok: false, reason: 'keine_subdomain' }) + assert.deepEqual(await an.analyseEligibility('c2', 'p4'), { ok: false, reason: 'kein_abo' }, 'Hosting-Abo reicht nicht') + assert.deepEqual(await an.analyseEligibility('c3', 'p5'), { ok: false, reason: 'kein_abo' }) + assert.deepEqual(await an.analyseEligibility('c1', 'p4'), { ok: false, reason: 'nicht_dein_projekt' }) + assert.deepEqual(await an.analyseEligibility('c1', 'gibts-nicht'), { ok: false, reason: 'nicht_dein_projekt' }) +}) + +test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => { + await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', consent: consent() }), { status: 403 }) + await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', consent: consent() }), { status: 403 }) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', consent: consent() }), { status: 409 }) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' }) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 }) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 }) + await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', consent: consent() }), { status: 400 }) + assert.equal(puts.length, 0) + assert.equal(analyseTickets().length, 0) +}) + +test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => { + const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) + assert.equal(changed, true) + assert.equal(d.featureKey, 'analyse') + assert.equal(d.state, 'active') + assert.equal(d.enabled, true) + assert.match(d.siteKey, /^[a-z0-9]{16,40}$/) + assert.equal(d.accentColor, '#1a73e8') + assert.equal(d.avvVersion, config.analyse.avvVersion) + assert.equal(d.avvAcceptedBy, 'customer') + assert.ok(d.avvAcceptedAt && d.activatedAt) + assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de'], active: true }]) + assert.deepEqual(rebuilds, ['p1']) + const tickets = analyseTickets() + assert.equal(tickets.length, 1) + assert.equal(tickets[0].topic, 'Datenschutzerklärung von Bäckerei um Hinweis auf Analyse ergänzen') + assert.match(tickets[0].details, /Funktion: analyse/) + assert.match(tickets[0].details, /Domain\(s\): baeckerei\.project\.webklar\.com, baeckerei\.de/) + assert.match(tickets[0].details, /https:\/\/baeckerei\.de\/datenschutz-analyse\.html/) + assert.match(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/) + assert.equal((await doc('c1', 'p1')).privacyTicketId, tickets[0].$id) + assert.equal(mails.sent.length, 1) + assert.equal(mails.sent[0].subject, 'Deine Website-Analyse ist aktiv – Bäckerei') + assert.deepEqual(mails.sent[0].to.map((t) => t.address), ['eins@example.invalid']) +}) + +test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => { + const before = await doc('c1', 'p1') + const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() }) + assert.equal(same.changed, false) + assert.equal(rebuilds.length, 0) + const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', consent: consent() }) + assert.equal(recolored.doc.accentColor, '#cc0000') + assert.equal(recolored.doc.siteKey, before.siteKey) + assert.deepEqual(rebuilds, ['p1']) + assert.equal(analyseTickets().length, 1) + assert.equal(mails.sent.length, 0) + assert.equal(puts.length, 0) +}) + +test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => { + const now = new Date('2026-10-05T10:00:00.000Z') + const { doc: d, changed } = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now }) + assert.equal(changed, true) + assert.equal(d.state, 'off') + assert.equal(d.enabled, false) + assert.equal(d.offSince, '2026-10-05T10:00:00.000Z') + assert.equal(puts.length, 1) + assert.equal(puts[0].active, false) + assert.deepEqual(rebuilds, ['p1']) + assert.equal((await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })).changed, false) + await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 }) +}) + +test('Wieder einschalten: gleicher siteKey, offSince leer, kein neues Ticket', async () => { + const before = await doc('c1', 'p1') + const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso' }) + assert.equal(d.siteKey, before.siteKey) + assert.equal(d.state, 'active') + assert.equal(d.offSince, '') + assert.equal(d.accentColor, '#111111') + assert.equal(d.avvAcceptedBy, 'Kenso') + assert.equal(analyseTickets().length, 1) +}) + +test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => { + // c2 hat nur Hosting-Abo, c3 gar keins: beide Analysen von Hand "aktiv" (z. B. Abo wurde gewechselt) + for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) { + const d = await sf.ensureFeatureDoc(c, p, 'analyse') + await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' }) + } + const now = new Date('2026-11-01T03:15:00.000Z') + const result = await an.runAboCheck({ now }) + assert.equal(result.checked, 3) + assert.equal(result.deactivated.length, 2) + assert.equal((await doc('c1', 'p1')).state, 'active', 'Wartungs-Abo bleibt aktiv') + for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) { + const d = await doc(c, p) + assert.equal(d.state, 'off') + assert.equal(d.offSince, now.toISOString()) + } + assert.deepEqual(mails.sent.map((m) => m.subject).sort(), ['Deine Website-Analyse wurde beendet – Hosting-Kunde', 'Deine Website-Analyse wurde beendet – Ohne Abo']) + mails.sent.length = 0 + assert.equal((await an.runAboCheck({ now })).deactivated.length, 0, 'zweiter Lauf: nichts mehr zu tun') + assert.equal(mails.sent.length, 0) +}) + +test('News-Jobs ignorieren Analyse-Dokumente', async () => { + const d = await sf.ensureFeatureDoc('c1', 'p2', 'analyse') + aw.seed('workorders', { $id: 't_an', status: 'Closed', woid: '1', topic: 'x' }) + await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('setup'), ticketId: 't_an' }) + assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), []) + assert.deepEqual(await sf.runActivation({ customerId: 'c1' }), []) + assert.equal((await doc('c1', 'p2')).state, 'setup') + const active = await doc('c1', 'p1') + assert.equal(await sf.findActiveBySiteKey(active.siteKey.slice(0, 16)), null, 'Analyse-siteKey ist kein News-Schlüssel') +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 7529cd8..b7ab648 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); -// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden +// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', @@ -15,6 +15,8 @@ const EXPECTED = [ 'K15', 'K16', 'K17', + 'K18', + 'K19', ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), 'T12', 'T13',