Analyse: Freischalten, Abschalten, Abo-Pruefung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 10:10:39 +00:00
1 parent 9e66f55c2c
commit f0eee196ed
10 files changed
+540 -32

No files matched your search

+11
View File
@@ -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) =>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K18 · Analyse aktiv (Website-Analyse)
Betreff: Deine Website-Analyse ist aktiv – {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Website-Analyse ist aktiv – {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Website-Analyse ist eingeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Aktiv</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Website-Analyse</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse ist aktiv</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>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.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auswertung öffnen &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K19 · Analyse beendet (Website-Analyse)
Betreff: Deine Website-Analyse wurde beendet – {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Website-Analyse wurde beendet – {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Website-Analyse wurde beendet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Beendet</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Website-Analyse</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse wurde beendet</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>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.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+1 -25
View File
@@ -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)
+212
View File
@@ -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 }
}
/** `<subdomain>.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 }
}
+33
View File
@@ -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
}
+4 -3
View File
@@ -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),
])
+3 -3
View File
@@ -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}`,