Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)
Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite. analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged, analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg, Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen). Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte). Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbc40252a3
commit
78170567b4
17 files changed
+616
-70
No files matched your search
@@ -110,6 +110,18 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite)
|
||||
if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
||||
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
|
||||
if (!old) return send(res, 404, { error: 'Website nicht gefunden' });
|
||||
db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key);
|
||||
onSitesChanged?.();
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
|
||||
@@ -166,3 +166,44 @@ test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
|
||||
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||
d2.close();
|
||||
});
|
||||
|
||||
test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404);
|
||||
assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400);
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
assert.equal(row(t.db).active, 1);
|
||||
const r = await t.call('POST', `/a/admin/sites/${KEY}/off`);
|
||||
assert.equal(r.status, 200);
|
||||
const s1 = row(t.db);
|
||||
assert.equal(s1.active, 0);
|
||||
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']);
|
||||
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200);
|
||||
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const pub = createPublicServer({ config: {}, db });
|
||||
const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() });
|
||||
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
|
||||
const pb = `http://127.0.0.1:${pub.address().port}`;
|
||||
const ab = `http://127.0.0.1:${adm.address().port}`;
|
||||
await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) });
|
||||
const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' },
|
||||
body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) });
|
||||
await ev();
|
||||
const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c;
|
||||
assert.equal(before, 1);
|
||||
assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200);
|
||||
await ev();
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before);
|
||||
assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true);
|
||||
pub.close(); adm.close();
|
||||
});
|
||||
+5
-1
@@ -22,7 +22,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 { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
@@ -182,6 +182,10 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
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))
|
||||
// danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets
|
||||
.then(() => runAnalyseReconcile({ now: new Date() }))
|
||||
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
|
||||
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
|
||||
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
|
||||
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
|
||||
@@ -1048,6 +1048,15 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inTime && type === 'wechsel') {
|
||||
// Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran)
|
||||
try {
|
||||
const { endAnalyseForCustomer } = await import('../services/analyse.js')
|
||||
await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt })
|
||||
} catch (err) {
|
||||
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
|
||||
}
|
||||
}
|
||||
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
|
||||
return {
|
||||
sub,
|
||||
|
||||
+210
-50
@@ -23,7 +23,7 @@ export const MISSING_PRIVACY_MESSAGE =
|
||||
'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
|
||||
|
||||
/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments }
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) }
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
@@ -220,18 +220,65 @@ function publishTicket(customerId, project, siteKey, reason) {
|
||||
})
|
||||
}
|
||||
|
||||
function removalTicket(customerId, project, siteKey) {
|
||||
const removalTopic = (project) => `Website-Analyse von ${projectName(project)} entfernen`
|
||||
/** Zeitpunkt dieser Abschaltung: kennzeichnet das Entfernen-Ticket (ein Ticket je Abschaltung). */
|
||||
const offMarker = (doc) => String(doc.offSince || doc.disabledAt || '')
|
||||
|
||||
function removalTicket(customerId, project, siteKey, offSince) {
|
||||
const name = projectName(project)
|
||||
return analyseDeps.ticket({
|
||||
customerId,
|
||||
project,
|
||||
featureKey: FEATURE_KEY,
|
||||
siteKey,
|
||||
topic: `Website-Analyse von ${name} entfernen`,
|
||||
details: removalTicketDetails({ name, domains: analyseHosts(project) }),
|
||||
topic: removalTopic(project),
|
||||
details: removalTicketDetails({ name, domains: analyseHosts(project), offSince }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Schon angelegtes Entfernen-Ticket dieser Abschaltung (Speichern der ID war gescheitert) oder null. Suchfehler werfen: dann lieber kein zweites Ticket. */
|
||||
async function findRemovalTicket(customerId, project, marker) {
|
||||
if (!marker) return null
|
||||
const tickets = await listDocuments(config.collections.workorders, [Query.equal('customerId', customerId), Query.limit(1000)])
|
||||
return tickets.find((t) => t.topic === removalTopic(project) && String(t.details || '').includes(`Abgeschaltet: ${marker}`)) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernen-Ticket fuer die aktuelle Abschaltung sicherstellen (idempotent): vorhandene ID gilt, sonst ein bereits
|
||||
* angelegtes Ticket uebernehmen, sonst neu anlegen. Gibt das (ggf. aktualisierte) Dokument zurueck. Wirft bei Fehlern.
|
||||
*/
|
||||
async function ensureRemovalTicket(doc, project) {
|
||||
if (doc.removalTicketId) return doc
|
||||
const marker = offMarker(doc)
|
||||
const found = await findRemovalTicket(doc.customerId, project, marker)
|
||||
const ticketId = found ? found.$id : (await removalTicket(doc.customerId, project, doc.siteKey || '', marker)).ticketId
|
||||
const saved = await analyseDeps.saveRemovalTicketId(doc.$id, ticketId)
|
||||
return saved || { ...doc, removalTicketId: ticketId }
|
||||
}
|
||||
|
||||
async function projectOf(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung). */
|
||||
async function sendK18(doc, project) {
|
||||
let attachments = null
|
||||
try {
|
||||
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
|
||||
} catch (err) {
|
||||
console.error(`[analyse] K18-Anhänge für Projekt ${doc.projectId} nicht lesbar:`, err.message)
|
||||
}
|
||||
if (attachments && (await mailSafe(doc.customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
|
||||
return updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) {
|
||||
const viaCustomer = by === 'customer'
|
||||
let accepted
|
||||
@@ -339,22 +386,55 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason)
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId })
|
||||
}
|
||||
if (needMail) {
|
||||
// K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung)
|
||||
let attachments = null
|
||||
try {
|
||||
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
|
||||
} catch (err) {
|
||||
console.error(`[analyse] K18-Anhänge für Projekt ${projectId} nicht lesbar:`, err.message)
|
||||
}
|
||||
if (attachments && (await mailSafe(customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
|
||||
}
|
||||
}
|
||||
if (needMail) doc = await sendK18(doc, project)
|
||||
return { doc, changed: changed || needTicket || needMail }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Kern aller Beendigungswege: Messdienst abschalten (stopSite), Dokument auf off + offSince,
|
||||
* Historie, optional Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Fehler beim Messdienst werfen
|
||||
* (Dokument bleibt dann unveraendert, der Abgleich holt nach); Historie, Neubau und Ticket nie.
|
||||
*/
|
||||
export async function endAnalyse({ doc, project, reason, now = new Date(), by = 'system', actor, tickets = true, rebuild = true }) {
|
||||
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('off'),
|
||||
offSince: now.toISOString(),
|
||||
})
|
||||
try {
|
||||
await analyseDeps.recordConsent({
|
||||
customerId: doc.customerId,
|
||||
projectId: doc.projectId,
|
||||
siteKey: doc.siteKey || '',
|
||||
event: 'off',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
|
||||
acceptedAt: now.toISOString(),
|
||||
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
|
||||
textformDate: '',
|
||||
textformChannel: '',
|
||||
})
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Nachweis scheitern
|
||||
console.warn(`[analyse] Historie (aus) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
console.log(`[analyse] Projekt ${doc.projectId} beendet (${reason || 'ausgeschaltet'})`)
|
||||
let result = updated
|
||||
if (project) {
|
||||
if (rebuild) await rebuildSafe(project)
|
||||
if (tickets && (await customerOf(doc.customerId).catch(() => null))) {
|
||||
try {
|
||||
result = await ensureRemovalTicket(updated, project)
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Ticket scheitern; der taegliche Abgleich holt es nach
|
||||
console.warn(`[analyse] Entfernen-Ticket für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { doc: result, changed: true }
|
||||
}
|
||||
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) {
|
||||
return withLocks([`analyse:${projectId}`], async () => {
|
||||
const project = await loadOwnProject(customerId, projectId)
|
||||
@@ -363,43 +443,32 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
|
||||
if (!existing || existing.state === 'off') return { doc: existing, changed: false }
|
||||
// Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter)
|
||||
if (existing.state === 'disabled' && by === 'customer') 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(),
|
||||
})
|
||||
try {
|
||||
await analyseDeps.recordConsent({
|
||||
customerId,
|
||||
projectId,
|
||||
siteKey: existing.siteKey || '',
|
||||
event: 'off',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
|
||||
acceptedAt: now.toISOString(),
|
||||
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
|
||||
textformDate: '',
|
||||
textformChannel: '',
|
||||
})
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Nachweis scheitern
|
||||
console.warn(`[analyse] Historie (aus) für Projekt ${projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
await rebuildSafe(project)
|
||||
let result = doc
|
||||
try {
|
||||
const { ticketId } = await removalTicket(customerId, project, existing.siteKey || '')
|
||||
result = await updateDocument(config.collections.portalFeatures, doc.$id, { removalTicketId: ticketId })
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Ticket scheitern
|
||||
console.warn(`[analyse] Entfernen-Ticket für Projekt ${projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
return { doc: result, changed: true }
|
||||
return endAnalyse({ doc: existing, project, reason: 'ausgeschaltet', now, by, actor })
|
||||
})
|
||||
}
|
||||
|
||||
const featureDocsOf = (extra = []) => listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), ...extra, Query.limit(1000)])
|
||||
|
||||
/**
|
||||
* Alle aktiven Analysen eines Kunden beenden (Kunde geloescht, Widerruf Wechsel). Ein Fehler bei einem Dokument
|
||||
* stoppt die uebrigen nicht; was bleibt, beendet der taegliche Abgleich. Bei mail:true bekommt der Kunde K19.
|
||||
*/
|
||||
export async function endAnalyseForCustomer(customerId, { reason, mail = false, now = new Date() } = {}) {
|
||||
const gone = reason === 'kunde_geloescht'
|
||||
const ended = []
|
||||
for (const doc of await featureDocsOf([Query.equal('customerId', customerId), Query.equal('state', 'active')])) {
|
||||
try {
|
||||
const project = await projectOf(doc.projectId).catch(() => null)
|
||||
await withLocks([`analyse:${doc.projectId}`], () => endAnalyse({ doc, project, reason, now, by: 'system', tickets: !gone, rebuild: !gone }))
|
||||
ended.push(doc.$id)
|
||||
if (mail) await mailSafe(customerId, 'K19', project || {})
|
||||
} catch (err) {
|
||||
console.error(`[analyse] Beenden (${reason}) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
return { ended }
|
||||
}
|
||||
|
||||
/** 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, [
|
||||
@@ -423,3 +492,94 @@ export async function runAboCheck({ now = new Date() } = {}) {
|
||||
}
|
||||
return { checked: docs.length, deactivated }
|
||||
}
|
||||
|
||||
const INELIGIBLE = (project) => (project.isPublic !== true ? 'nicht_oeffentlich' : !project.subdomain ? 'keine_subdomain' : null)
|
||||
|
||||
/** Nach einer Aenderung am Projekt (Subdomain, Oeffentlichkeit): Berechtigung weg => beenden, sonst Domains nachziehen. */
|
||||
export async function analyseOnProjectChanged(projectId) {
|
||||
const project = await projectOf(projectId)
|
||||
const docs = await featureDocsOf([Query.equal('projectId', projectId), Query.equal('state', 'active')])
|
||||
for (const doc of docs) {
|
||||
await withLocks([`analyse:${projectId}`], async () => {
|
||||
if (!project) return endAnalyse({ doc, project: null, reason: 'projekt_geloescht', tickets: false, rebuild: false })
|
||||
const reason = INELIGIBLE(project)
|
||||
if (reason) return endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich' })
|
||||
const domains = analyseDomains(project)
|
||||
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId, domains, active: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperre/Loeschfrist (siteFeatures.disable): Messdienst per stopSite abschalten (auch ohne Projekt), offSince setzen,
|
||||
* Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Wirft nie.
|
||||
*/
|
||||
export async function analyseDisabled(doc) {
|
||||
try {
|
||||
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
|
||||
} catch (err) {
|
||||
console.warn(`[analyse] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
try {
|
||||
let current = doc
|
||||
if (!doc.offSince && doc.siteKey) {
|
||||
current = await updateDocument(config.collections.portalFeatures, doc.$id, { offSince: doc.disabledAt || new Date().toISOString() })
|
||||
}
|
||||
const project = await projectOf(doc.projectId).catch(() => null)
|
||||
if (!project || !current.siteKey || !(await customerOf(doc.customerId).catch(() => null))) return
|
||||
await rebuildSafe(project)
|
||||
await ensureRemovalTicket(current, project)
|
||||
} catch (err) {
|
||||
console.warn(`[analyse] Nacharbeit nach Sperre für ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taeglicher Abgleich (nach runAboCheck), je Analyse-Dokument unabhaengig:
|
||||
* - active: Projekt oder Kunde weg => beenden (ohne Ticket/Neubau); nicht mehr berechtigt => beenden; sonst Domains
|
||||
* nachziehen (putSite) und K18 wiederholen, falls sie fehlt;
|
||||
* - off/disabled: Messdienst abschalten (idempotent) und fehlendes Entfernen-Ticket nachholen (nur Abschaltungen ab der
|
||||
* Fassung, also nach Livegang).
|
||||
*/
|
||||
export async function runAnalyseReconcile({ now = new Date() } = {}) {
|
||||
const docs = await featureDocsOf()
|
||||
const stats = { checked: docs.length, ended: [], stopped: [], tickets: [] }
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
if (doc.state === 'active') {
|
||||
await withLocks([`analyse:${doc.projectId}`], async () => {
|
||||
const project = await projectOf(doc.projectId)
|
||||
if (!project) {
|
||||
await endAnalyse({ doc, project: null, reason: 'projekt_geloescht', now, tickets: false, rebuild: false })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
if (!(await customerOf(doc.customerId))) {
|
||||
await endAnalyse({ doc, project, reason: 'kunde_geloescht', now, tickets: false, rebuild: false })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
if (INELIGIBLE(project)) {
|
||||
await endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich', now })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
const domains = analyseDomains(project)
|
||||
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains, active: true })
|
||||
if (!doc.activatedMailAt) await sendK18(doc, project)
|
||||
})
|
||||
} else if ((doc.state === 'off' || doc.state === 'disabled') && SITE_KEY_RE.test(doc.siteKey || '')) {
|
||||
await analyseDeps.client.stopSite(doc.siteKey)
|
||||
stats.stopped.push(doc.$id)
|
||||
const marker = offMarker(doc)
|
||||
if (!doc.removalTicketId && marker >= config.analyse.avvVersion) {
|
||||
const project = await projectOf(doc.projectId)
|
||||
if (project && (await customerOf(doc.customerId))) {
|
||||
await ensureRemovalTicket(doc, project)
|
||||
stats.tickets.push(doc.$id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function privacyTicketDetails({ name, domains, rebuildNote = '' }) {
|
||||
}
|
||||
|
||||
/** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */
|
||||
export function removalTicketDetails({ name, domains }) {
|
||||
export function removalTicketDetails({ name, domains, offSince = '' }) {
|
||||
return [
|
||||
...headLines({ name, domains }),
|
||||
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
|
||||
@@ -47,6 +47,7 @@ export function removalTicketDetails({ name, domains }) {
|
||||
'1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.',
|
||||
'2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.',
|
||||
'3. Nicht auf das Stundenkontingent anrechnen.',
|
||||
...(offSince ? ['', `Abgeschaltet: ${offSince}`] : []), // Kennzeichen je Abschaltung (verhindert doppelte Tickets)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ export function createAnalyticsClient(opts = {}) {
|
||||
putSite(siteKey, { projectId, domains, active }) {
|
||||
return request('PUT', sitePath(siteKey), { projectId, domains, active })
|
||||
},
|
||||
/** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */
|
||||
async stopSite(siteKey) {
|
||||
try {
|
||||
return await request('POST', `${sitePath(siteKey)}/off`)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return { ok: true, missing: true }
|
||||
throw err
|
||||
}
|
||||
},
|
||||
deleteSite(siteKey) {
|
||||
return request('DELETE', sitePath(siteKey))
|
||||
},
|
||||
|
||||
@@ -274,6 +274,14 @@ export async function deleteCustomerWithPortalAccess(customerId) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach
|
||||
try {
|
||||
const { endAnalyseForCustomer } = await import('./analyse.js')
|
||||
await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false })
|
||||
} catch (err) {
|
||||
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
|
||||
}
|
||||
|
||||
const portalAccess = await getPortalAccessByCustomerId(customerId)
|
||||
if (portalAccess) {
|
||||
await deleteDocument(
|
||||
|
||||
@@ -517,6 +517,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
|
||||
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
// Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht
|
||||
try {
|
||||
const { analyseOnProjectChanged } = await import('./analyse.js')
|
||||
await analyseOnProjectChanged(projectId)
|
||||
} catch (err) {
|
||||
console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
return {
|
||||
project: updated,
|
||||
deploy: deployResult,
|
||||
|
||||
@@ -302,13 +302,12 @@ export async function disable(docId, { by, adminNote } = {}) {
|
||||
return doc
|
||||
}
|
||||
|
||||
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert.
|
||||
// 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) {
|
||||
try {
|
||||
const { analyseDeps, analyseDomains } = await import('./analyse.js')
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||
await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false })
|
||||
const { analyseDisabled } = await import('./analyse.js')
|
||||
await analyseDisabled(doc)
|
||||
} catch (err) {
|
||||
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
|
||||
+184
-5
@@ -24,7 +24,7 @@ const an = await import('../server/services/analyse.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const cl = await import('../server/services/analyseConsent.js')
|
||||
const priv = await import('../server/services/analysePrivacy.js')
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { updateDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const mails = mailSink()
|
||||
@@ -35,8 +35,9 @@ after(() => {
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const stops = []
|
||||
const rebuilds = []
|
||||
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) }
|
||||
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), stopSite: async (siteKey) => stops.push(siteKey) }
|
||||
an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id)
|
||||
|
||||
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
|
||||
@@ -67,6 +68,7 @@ aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
rebuilds.length = 0
|
||||
mails.sent.length = 0
|
||||
})
|
||||
@@ -156,8 +158,8 @@ test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts'
|
||||
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.equal(puts.length, 0)
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
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 })
|
||||
@@ -466,7 +468,7 @@ test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = z
|
||||
assert.match(ticket.details, /fünf Werktagen/)
|
||||
assert.match(ticket.details, /Stundenkontingent/)
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...staff() })
|
||||
const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, by: 'Kenso' })
|
||||
const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: new Date(NOW.getTime() + 3600000), by: 'Kenso' })
|
||||
assert.equal(removal().length, 2)
|
||||
assert.notEqual(off2.doc.removalTicketId, off1.doc.removalTicketId)
|
||||
})
|
||||
@@ -524,3 +526,180 @@ test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-T
|
||||
}
|
||||
an.analyseDeps.rebuild = original
|
||||
})
|
||||
|
||||
// ---- Task 9: alle Beendigungswege ----
|
||||
const FULL = { avvVersion: '2026-10-02', avvAcceptedAt: '2026-10-03T08:00:00.000Z', avvAcceptedBy: 'x', activatedAt: '2026-10-03T08:00:00.000Z', activatedMailAt: '2026-10-03T08:00:01.000Z', privacyTicketId: 'pt' }
|
||||
async function seedFeature(customerId, projectId, state, extra = {}) {
|
||||
const d = await sf.ensureFeatureDoc(customerId, projectId, 'analyse')
|
||||
return updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields(state), accentColor: '#111111', ...FULL, ...extra })
|
||||
}
|
||||
const removalTopics = (name) => topics().filter((t) => t === `Website-Analyse von ${name} entfernen`)
|
||||
|
||||
test('Kunde gelöscht: endAnalyseForCustomer stoppt jeden aktiven Schlüssel, off, kein Ticket, keine Mail, Fehler werden geschluckt', async () => {
|
||||
aw.seed('customers', { $id: 'k1', name: 'Weg Eins', email: 'weg1@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'k1p1', customerId: 'k1', projectName: 'KP1', subdomain: 'kp1', isPublic: true, repoFullName: 'WEBklar/kp1' })
|
||||
aw.seed('websiteProjects', { $id: 'k1p2', customerId: 'k1', projectName: 'KP2', subdomain: 'kp2', isPublic: true })
|
||||
const a = await seedFeature('k1', 'k1p1', 'active')
|
||||
const b = await seedFeature('k1', 'k1p2', 'active')
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async (k) => { stops.push(k); if (k === b.siteKey) throw new Error('Sammeldienst weg') }
|
||||
const r = await an.endAnalyseForCustomer('k1', { reason: 'kunde_geloescht', mail: false })
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.deepEqual(stops.sort(), [a.siteKey, b.siteKey].sort())
|
||||
assert.equal((await doc('k1', 'k1p1')).state, 'off')
|
||||
assert.ok((await doc('k1', 'k1p1')).offSince)
|
||||
assert.equal((await doc('k1', 'k1p2')).state, 'active', 'Fehler bei einem Dokument: Abgleich holt nach')
|
||||
assert.deepEqual(r.ended, [a.$id])
|
||||
assert.equal(removalTopics('KP1').length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich: Projekt gelöscht, Kunde gelöscht => stopSite, off, offSince, kein Ticket', async () => {
|
||||
aw.seed('customers', { $id: 'k2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'k2p', customerId: 'k2', projectName: 'KP3', subdomain: 'kp3', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k3p', customerId: 'kgibtsnicht', projectName: 'KP4', subdomain: 'kp4', isPublic: true })
|
||||
const gone = await seedFeature('k2', 'k2p', 'active')
|
||||
const orphan = await seedFeature('kgibtsnicht', 'k3p', 'active')
|
||||
await deleteDocument(config.collections.websiteProjects, 'k2p')
|
||||
const now = new Date('2026-11-02T03:00:00.000Z')
|
||||
await an.runAnalyseReconcile({ now })
|
||||
assert.ok(stops.includes(gone.siteKey) && stops.includes(orphan.siteKey))
|
||||
for (const [c, p] of [['k2', 'k2p'], ['kgibtsnicht', 'k3p']]) {
|
||||
const d = await doc(c, p)
|
||||
assert.equal(d.state, 'off')
|
||||
assert.equal(d.offSince, now.toISOString())
|
||||
}
|
||||
assert.equal(removalTopics('KP3').length + removalTopics('KP4').length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich: geänderte liveDomain => putSite mit neuer Domainliste; off-Dokument => stopSite; K18-Wiederholung genau einmal', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k4p', customerId: 'c1', projectName: 'KP5', subdomain: 'kp5', liveDomain: 'neu-domain.de', isPublic: true })
|
||||
const live = await seedFeature('c1', 'k4p', 'active', { activatedMailAt: '' })
|
||||
aw.seed('websiteProjects', { $id: 'k5p', customerId: 'c1', projectName: 'KP6', subdomain: 'kp6', isPublic: true })
|
||||
const off = await seedFeature('c1', 'k5p', 'off', { offSince: '2026-10-03T09:00:00.000Z', removalTicketId: 'vorhanden' })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.deepEqual(puts.filter((p) => p.siteKey === live.siteKey), [{ siteKey: live.siteKey, projectId: 'k4p', domains: ['kp5.project.webklar.com', 'neu-domain.de', 'www.neu-domain.de'], active: true }])
|
||||
assert.ok(stops.includes(off.siteKey))
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.ok((await doc('c1', 'k4p')).activatedMailAt)
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(mails.sent.length, 1, 'Marker gesetzt: keine zweite K18')
|
||||
})
|
||||
|
||||
test('Domain/Öffentlichkeit: analyseOnProjectChanged beendet bei nicht öffentlich (Ticket), putSite bei neuer Subdomain', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k6p', customerId: 'c1', projectName: 'KP7', subdomain: 'kp7', isPublic: true, repoFullName: 'WEBklar/kp7' })
|
||||
const d = await seedFeature('c1', 'k6p', 'active')
|
||||
await updateDocument(config.collections.websiteProjects, 'k6p', { subdomain: 'kp7neu' })
|
||||
await an.analyseOnProjectChanged('k6p')
|
||||
assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'k6p', domains: ['kp7neu.project.webklar.com'], active: true }])
|
||||
assert.equal(stops.length, 0)
|
||||
puts.length = 0
|
||||
await updateDocument(config.collections.websiteProjects, 'k6p', { isPublic: false })
|
||||
await an.analyseOnProjectChanged('k6p')
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
const after = await doc('c1', 'k6p')
|
||||
assert.equal(after.state, 'off')
|
||||
assert.ok(after.offSince)
|
||||
assert.ok(after.removalTicketId)
|
||||
assert.equal(removalTopics('KP7').length, 1)
|
||||
assert.deepEqual(rebuilds, ['k6p'])
|
||||
await an.analyseOnProjectChanged('k6p') // kein aktives Dokument mehr
|
||||
assert.equal(removalTopics('KP7').length, 1)
|
||||
await an.analyseOnProjectChanged('gibts-nicht')
|
||||
})
|
||||
|
||||
test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu und legt Entfernen-Ticket genau einmal an', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k7p', customerId: 'c1', projectName: 'KP8', subdomain: 'kp8', isPublic: true, repoFullName: 'WEBklar/kp8' })
|
||||
const d = await seedFeature('c1', 'k7p', 'active')
|
||||
const r = await sf.disable(d.$id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||
assert.equal(r.state, 'disabled')
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
assert.equal(puts.length, 0)
|
||||
const after = await doc('c1', 'k7p')
|
||||
assert.ok(after.offSince)
|
||||
assert.ok(after.removalTicketId)
|
||||
assert.deepEqual(rebuilds, ['k7p'])
|
||||
assert.equal(removalTopics('KP8').length, 1)
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP8').length, 1)
|
||||
assert.equal((await doc('c1', 'k7p')).state, 'disabled')
|
||||
})
|
||||
|
||||
test('Löschfrist ohne Projekt: stopSite, kein Ticket, kein Neubau, kein Fehler', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k8p', customerId: 'c1', projectName: 'KP9', subdomain: 'kp9', isPublic: true })
|
||||
const d = await seedFeature('c1', 'k8p', 'active')
|
||||
await deleteDocument(config.collections.websiteProjects, 'k8p')
|
||||
await sf.disable(d.$id, { adminNote: 'x' })
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
assert.equal(removalTopics('KP9').length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich holt fehlendes Entfernen-Ticket nach (off/disabled), idempotent; Altfälle vor der Fassung nicht', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k9p', customerId: 'c1', projectName: 'KP10', subdomain: 'kp10', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k10p', customerId: 'c1', projectName: 'KP11', subdomain: 'kp11', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k11p', customerId: 'c1', projectName: 'KP12', subdomain: 'kp12', isPublic: true })
|
||||
await seedFeature('c1', 'k9p', 'off', { offSince: '2026-10-04T09:00:00.000Z' })
|
||||
await seedFeature('c1', 'k10p', 'disabled', { offSince: '', disabledAt: '2026-10-04T09:00:00.000Z' })
|
||||
await seedFeature('c1', 'k11p', 'off', { offSince: '2026-09-20T09:00:00.000Z' })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP10').length, 1)
|
||||
assert.equal(removalTopics('KP11').length, 1)
|
||||
assert.equal(removalTopics('KP12').length, 0, 'vor Livegang beendet')
|
||||
assert.ok((await doc('c1', 'k9p')).removalTicketId)
|
||||
})
|
||||
|
||||
test('Ticket angelegt, Speichern der ID scheiterte: Abgleich übernimmt das vorhandene Ticket (kein zweites)', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k12p', customerId: 'c1', projectName: 'KP13', subdomain: 'kp13', isPublic: true })
|
||||
const off = '2026-10-04T09:00:00.000Z'
|
||||
await seedFeature('c1', 'k12p', 'off', { offSince: off })
|
||||
// Zustand nach einem Fehler beim Speichern: Ticket fuer genau diese Abschaltung existiert, removalTicketId fehlt
|
||||
const orig = an.analyseDeps.ticket
|
||||
let created = null
|
||||
an.analyseDeps.ticket = async (a) => (created = await orig(a))
|
||||
const realUpdate = an.analyseDeps.saveRemovalTicketId
|
||||
an.analyseDeps.saveRemovalTicketId = async () => { throw new Error('Appwrite weg') }
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
an.analyseDeps.saveRemovalTicketId = realUpdate
|
||||
an.analyseDeps.ticket = orig
|
||||
assert.ok(created, 'Ticket entstand')
|
||||
assert.ok(!(await doc('c1', 'k12p')).removalTicketId, 'ID nicht gespeichert')
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP13').length, 1)
|
||||
assert.equal((await doc('c1', 'k12p')).removalTicketId, created.ticketId)
|
||||
})
|
||||
|
||||
test('Aus/An/Aus: zweite Abschaltung bekommt trotz offenem ersten Ticket ein eigenes (Marker je Abschaltung)', async () => {
|
||||
const tickets = [...aw.docs('workorders').values()].filter((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
assert.equal(tickets.length, 2)
|
||||
assert.notEqual(tickets[0].details, tickets[1].details)
|
||||
})
|
||||
|
||||
test('deleteCustomerWithPortalAccess: Analyse aus (kein Ticket, keine Mail), Löschen gelingt auch wenn stopSite wirft', async () => {
|
||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||
aw.seed('customers', { $id: 'kd1', name: 'Löschen Eins', email: 'loe1@example.invalid' })
|
||||
aw.seed('customers', { $id: 'kd2', name: 'Löschen Zwei', email: 'loe2@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'kd1p', customerId: 'kd1', projectName: 'KD1', subdomain: 'kd1', isPublic: true, repoFullName: 'WEBklar/kd1' })
|
||||
aw.seed('websiteProjects', { $id: 'kd2p', customerId: 'kd2', projectName: 'KD2', subdomain: 'kd2', isPublic: true })
|
||||
const a = await seedFeature('kd1', 'kd1p', 'active')
|
||||
const b = await seedFeature('kd2', 'kd2p', 'active')
|
||||
await deleteCustomerWithPortalAccess('kd1')
|
||||
assert.deepEqual(stops, [a.siteKey])
|
||||
assert.equal((await doc('kd1', 'kd1p')).state, 'off')
|
||||
assert.equal(removalTopics('KD1').length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(aw.docs('customers').has('kd1'), false)
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async () => { throw new Error('weg') }
|
||||
await deleteCustomerWithPortalAccess('kd2')
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.equal(aw.docs('customers').has('kd2'), false)
|
||||
assert.equal((await doc('kd2', 'kd2p')).state, 'active', 'bleibt für den Abgleich (Kunde fehlt)')
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal((await doc('kd2', 'kd2p')).state, 'off')
|
||||
assert.ok(stops.includes(b.siteKey))
|
||||
})
|
||||
@@ -33,11 +33,13 @@ after(() => {
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const stops = []
|
||||
const statsCalls = []
|
||||
let statsFail = false
|
||||
const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' })
|
||||
an.analyseDeps.client = {
|
||||
putSite: async (siteKey, body) => puts.push({ siteKey, ...body }),
|
||||
stopSite: async (siteKey) => stops.push(siteKey),
|
||||
getStats: async (siteKey, range) => {
|
||||
statsCalls.push({ siteKey, range })
|
||||
if (statsFail) throw Object.assign(new Error('x'), { status: 502 })
|
||||
@@ -71,6 +73,7 @@ const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
statsCalls.length = 0
|
||||
statsFail = false
|
||||
config.analyse.enabled = true
|
||||
@@ -132,7 +135,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => {
|
||||
assert.equal(view.accentColor, '#112233')
|
||||
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
|
||||
assert.deepEqual(off.body, { ok: true, state: 'off' })
|
||||
assert.equal(puts[1].active, false)
|
||||
assert.equal(stops.length, 1)
|
||||
const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie })
|
||||
assert.equal(notElig.status, 409)
|
||||
})
|
||||
@@ -270,18 +273,19 @@ test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async
|
||||
assert.equal(staff.doc.state, 'off')
|
||||
})
|
||||
|
||||
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => {
|
||||
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst per stopSite', async () => {
|
||||
await adminPost('p6', { on: true, ...tf() })
|
||||
const cur = await featureDoc('c1', 'p6')
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
await sf.disable(cur.$id, { by: 'Test' })
|
||||
assert.equal(puts.length, 1)
|
||||
assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false })
|
||||
assert.deepEqual(stops, [cur.siteKey])
|
||||
assert.equal(puts.length, 0)
|
||||
// Ausfall des Sammeldienstes verhindert das Abschalten nicht
|
||||
const real = an.analyseDeps.client.putSite
|
||||
an.analyseDeps.client.putSite = async () => { throw new Error('weg') }
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async () => { throw new Error('weg') }
|
||||
const again = await sf.disable(cur.$id, { by: 'Test' })
|
||||
an.analyseDeps.client.putSite = real
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.equal(again.state, 'disabled')
|
||||
})
|
||||
|
||||
|
||||
@@ -92,3 +92,15 @@ test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', ()
|
||||
test('heatToken ohne heatSecret => 503', () => {
|
||||
assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503)
|
||||
})
|
||||
|
||||
test('stopSite: POST /off mit Bearer, 404 zaehlt als erledigt, andere Fehler werfen', async () => {
|
||||
reset()
|
||||
await mk().stopSite('abcdefghij123456')
|
||||
assert.equal(calls[0].method, 'POST')
|
||||
assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456/off')
|
||||
assert.equal(calls[0].auth, 'Bearer tok-secret')
|
||||
reset(() => ({ status: 404, body: { error: 'x' } }))
|
||||
assert.deepEqual(await mk().stopSite('abcdefghij123456'), { ok: true, missing: true })
|
||||
reset(() => ({ status: 500, body: {} }))
|
||||
await assert.rejects(mk().stopSite('abcdefghij123456'), (e) => e.status === 500)
|
||||
})
|
||||
@@ -60,3 +60,15 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
|
||||
await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404)
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0)
|
||||
})
|
||||
|
||||
test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledigt', async () => {
|
||||
const KEY2 = 'fedcba9876543210fedc'
|
||||
await client.putSite(KEY2, { projectId: 'p2', domains: [DOMAIN], active: true })
|
||||
await client.stopSite(KEY2)
|
||||
const row = db.prepare('SELECT active, offSince FROM sites WHERE siteKey = ?').get(KEY2)
|
||||
assert.equal(row.active, 0)
|
||||
assert.ok(row.offSince)
|
||||
await client.stopSite(KEY2) // zweiter Aufruf aendert nichts
|
||||
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
|
||||
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
|
||||
})
|
||||
@@ -30,6 +30,10 @@ before(async () => {
|
||||
{ repoFullName: 'knso/alt', projectName: 'alt', subdomain: 'alt' },
|
||||
{ repoFullName: 'knso/synced-sub', projectName: 'synced-sub', status: 'synced', subdomain: 'synced-sub' },
|
||||
{ repoFullName: 'knso/lead', projectName: 'lead', status: 'deployed', subdomain: 'lead' },
|
||||
{ $id: 'wp_an', repoFullName: 'knso/alt', projectName: 'Analyse-Projekt', subdomain: 'analyseprojekt', isPublic: true, projectType: 'website' },
|
||||
],
|
||||
portalFeatures: [
|
||||
{ $id: 'wp_an_feat', customerId: 'cx', projectId: 'wp_an', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wpansitekey01234567' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -111,3 +115,30 @@ test('Repo-Liste hat eine Obergrenze', async () => {
|
||||
assert.equal(repos.length, 2000)
|
||||
assert.equal(capGitea.calls.filter((c) => c.path === '/api/v1/repos/search').length, 40)
|
||||
})
|
||||
|
||||
test('updateWebsiteProject: neue Subdomain => putSite mit neuer Domain; nicht öffentlich => stopSite; Hook-Fehler ändert das Ergebnis nicht', async () => {
|
||||
const { updateWebsiteProject } = await import('../server/services/giteaAdmin.js')
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const calls = []
|
||||
const real = analyseDeps.client
|
||||
analyseDeps.client = {
|
||||
putSite: async (k, b) => calls.push(['put', k, b]),
|
||||
stopSite: async (k) => calls.push(['stop', k]),
|
||||
}
|
||||
try {
|
||||
const r1 = await updateWebsiteProject('wp_an', { subdomain: 'analyseneu', projectType: 'website' })
|
||||
assert.equal(r1.subdomain, 'analyseneu')
|
||||
assert.deepEqual(calls, [['put', 'wpansitekey01234567', { projectId: 'wp_an', domains: ['analyseneu.project.webklar.com'], active: true }]])
|
||||
calls.length = 0
|
||||
const r2 = await updateWebsiteProject('wp_an', { projectType: 'preview' })
|
||||
assert.equal(r2.isPublic, false)
|
||||
assert.deepEqual(calls, [['stop', 'wpansitekey01234567']])
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
await updateDocument(config.collections.portalFeatures, 'wp_an_feat', { state: 'active', enabled: true })
|
||||
analyseDeps.client = { putSite: async () => { throw new Error('weg') }, stopSite: async () => { throw new Error('weg') } }
|
||||
const r3 = await updateWebsiteProject('wp_an', { subdomain: 'nochneu', projectType: 'website' })
|
||||
assert.equal(r3.subdomain, 'nochneu')
|
||||
} finally {
|
||||
analyseDeps.client = real
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const aw = await startAppwriteMock({
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
hourCredits: schemaKeys('hourCredits'),
|
||||
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
@@ -28,6 +29,7 @@ const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
|
||||
const mails = mailSink()
|
||||
@@ -396,6 +398,61 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
|
||||
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
|
||||
})
|
||||
|
||||
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
|
||||
async function withActiveAnalyse(fn) {
|
||||
const stops = []
|
||||
const real = analyseDeps.client
|
||||
analyseDeps.client = { stopSite: async (k) => { stops.push(k) } }
|
||||
aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' })
|
||||
try {
|
||||
return await fn(stops)
|
||||
} finally {
|
||||
analyseDeps.client = real
|
||||
}
|
||||
}
|
||||
const wechselIn = async (days) => {
|
||||
const consentAt = daysAgo(days)
|
||||
await recordContractEvent({
|
||||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||||
})
|
||||
}
|
||||
|
||||
test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||||
const d = aw.docs('portalFeatures').get('wf_feat')
|
||||
assert.equal(d.state, 'off')
|
||||
assert.ok(d.offSince)
|
||||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||||
})
|
||||
// wirft der Messdienst, bleibt der Widerruf 200
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async () => {
|
||||
analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } }
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(30)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
const consentAt = daysAgo(30)
|
||||
|
||||
@@ -315,13 +315,13 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () =
|
||||
aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' })
|
||||
const puts = []
|
||||
const ok = analyseDeps.client
|
||||
analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } }
|
||||
analyseDeps.client = { putSite: async (k, b) => { puts.push(['put', k, b]) }, stopSite: async (k) => { puts.push(['stop', k]) } }
|
||||
try {
|
||||
const plan = await findDueDeletions(JAN_2026)
|
||||
assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat'))
|
||||
const result = await applyDeletions(plan)
|
||||
assert.deepEqual(result.failed, [])
|
||||
assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]])
|
||||
assert.deepEqual(puts, [['stop', 'abcdefghij234567']])
|
||||
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled')
|
||||
} finally {
|
||||
analyseDeps.client = ok
|
||||
|
||||
Reference in new issue
Block a user