Analyse: Freischalten, Abschalten, Abo-Pruefung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e66f55c2c
commit
f0eee196ed
10 files changed
+540
-32
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user