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. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Aktiv |
+
|
+
+
+ |
+| Deine Website-Analyse ist aktiv |
+Hallo {{kunde_name}}, die Website-Analyse für Deine Website ist eingeschaltet. Besucher sehen jetzt einen Hinweis-Banner, Du findest die Auswertung im Kundenportal. Wir ergänzen Deine Datenschutzerklärung um einen Hinweis auf die Analyse und melden uns, sobald das erledigt ist. |
+| Funktion | Website-Analyse | | Projekt | {{projekt}} |
|
+ |
+Viele Grüße Dein WEBklar-Team |
+
+ |
+| Kundenportal |
+WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler Telefon 0176 23726355 · support@webklar.com Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein |
+
+ |
+
+
+
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. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Beendet |
+
|
+
+
+ |
+| Deine Website-Analyse wurde beendet |
+Hallo {{kunde_name}}, die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde oder Du die Analyse ausgeschaltet hast. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht. |
+| Funktion | Website-Analyse | | Projekt | {{projekt}} |
|
+ |
+Viele Grüße Dein WEBklar-Team |
+
+ |
+| Kundenportal |
+WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler Telefon 0176 23726355 · support@webklar.com Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein |
+
+ |
+
+
+
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',