Analyse: Routen fuer Portal und WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
696f17b470
commit
f4c8b291d7
7 files changed
+416
-16
No files matched your search
@@ -33,6 +33,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
|
||||
import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -83,6 +84,7 @@ app.use('/api/admin/employees', adminEmployeesRoutes)
|
||||
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
||||
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
||||
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. AVV wird mündlich bestätigt.
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { activateAnalyse, deactivateAnalyse } from '../../services/analyse.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
||||
if (status === 500) console.error('[admin-analyse]', err.message)
|
||||
res.status(status).json({ error: status === 500 ? 'Die Analyse konnte nicht geändert werden' : err.message })
|
||||
}
|
||||
|
||||
router.post('/:projectId', async (req, res) => {
|
||||
try {
|
||||
if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' })
|
||||
const { on, avvConfirmedVerbally } = req.body || {}
|
||||
if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' })
|
||||
if (on && avvConfirmedVerbally !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat' })
|
||||
}
|
||||
let project
|
||||
try {
|
||||
project = await getDocument(config.collections.websiteProjects, req.params.projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
throw err
|
||||
}
|
||||
const customerId = project.customerId
|
||||
if (!customerId) return res.status(400).json({ error: 'Projekt hat keinen Kunden' })
|
||||
const by = String(req.appwriteUser?.name || req.appwriteUser?.email || 'Mitarbeiter').slice(0, 100)
|
||||
const { doc } = on
|
||||
? await activateAnalyse({
|
||||
customerId,
|
||||
projectId: project.$id,
|
||||
consent: { accepted: true, avvVersion: config.analyse.avvVersion },
|
||||
by,
|
||||
})
|
||||
: await deactivateAnalyse({ customerId, projectId: project.$id, by })
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
+126
-2
@@ -14,18 +14,54 @@ import {
|
||||
runActivation,
|
||||
} from '../services/siteFeatures.js'
|
||||
|
||||
import {
|
||||
ELIGIBILITY_MESSAGES,
|
||||
activateAnalyse,
|
||||
analyseDeps,
|
||||
analyseDomains,
|
||||
analyseEligibility,
|
||||
deactivateAnalyse,
|
||||
} from '../services/analyse.js'
|
||||
|
||||
const router = Router()
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
|
||||
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
|
||||
function fail(res, err, fallback) {
|
||||
const own = err.status >= 400 && err.status < 500 && !err.type
|
||||
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
|
||||
if (own) return res.status(err.status).json({ error: err.message })
|
||||
console.error('[funktionen]', err.status || '', err.type || '', err.message)
|
||||
return res.status(500).json({ error: fallback })
|
||||
}
|
||||
|
||||
const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet'
|
||||
const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
async function analyseView(customerId, projectId) {
|
||||
if (!config.analyse.enabled) return { comingSoon: true }
|
||||
const [doc, eligibility] = await Promise.all([getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId)])
|
||||
return {
|
||||
state: doc?.state || 'off',
|
||||
accentColor: doc?.accentColor || '#111111',
|
||||
eligible: eligibility.ok,
|
||||
reason: eligibility.reason || null,
|
||||
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// Schalter aus: alle Analyse-Routen 409
|
||||
function requireAnalyseEnabled(_req, res, next) {
|
||||
if (!config.analyse.enabled) return res.status(409).json({ error: ANALYSE_OFF })
|
||||
next()
|
||||
}
|
||||
|
||||
function analyseProjectId(value) {
|
||||
if (typeof value !== 'string' || !value) throw Object.assign(new Error('Bitte wähle ein Projekt.'), { status: 400 })
|
||||
return value
|
||||
}
|
||||
|
||||
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
@@ -33,8 +69,10 @@ router.get('/', requireSession, async (req, res) => {
|
||||
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
|
||||
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
await backfillInvoiceLinks(customerId)
|
||||
const projects = await listCustomerFeatures(customerId)
|
||||
for (const p of projects) p.analyse = await analyseView(customerId, p.projectId)
|
||||
res.json({
|
||||
projects: await listCustomerFeatures(customerId),
|
||||
projects,
|
||||
setupEstimate: config.funktionen.setupEstimate,
|
||||
consentText: config.funktionen.consentText,
|
||||
})
|
||||
@@ -110,4 +148,90 @@ router.post('/news/checkout', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
const analyseGuard = [requireSession, requireAnalyseEnabled]
|
||||
|
||||
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
||||
}
|
||||
const { projectId, accentColor, consent } = req.body || {}
|
||||
const { doc } = await activateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(projectId),
|
||||
accentColor,
|
||||
consent,
|
||||
by: 'customer',
|
||||
})
|
||||
res.json({ ok: true, state: doc.state })
|
||||
} catch (err) {
|
||||
fail(res, err, ANALYSE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
||||
}
|
||||
const { doc } = await deactivateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(req.body?.projectId),
|
||||
by: 'customer',
|
||||
})
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
} catch (err) {
|
||||
fail(res, err, ANALYSE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
// Eigenes Projekt und Analyse-Dokument mit siteKey (aktiv oder aus: Daten liegen noch bis zu 60 Tage)
|
||||
async function ownAnalyseDoc(req, projectId) {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const project = await assertOwnProject(customerId, analyseProjectId(projectId))
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'analyse')
|
||||
return { project, doc }
|
||||
}
|
||||
|
||||
router.get('/analyse/stats', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
const range = String(req.query.range || '30')
|
||||
if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum' })
|
||||
const { doc } = await ownAnalyseDoc(req, req.query.projectId)
|
||||
if (!doc?.siteKey || !['active', 'off'].includes(doc.state)) {
|
||||
return res.status(409).json({ error: 'Für dieses Projekt gibt es keine Auswertung.' })
|
||||
}
|
||||
let stats
|
||||
try {
|
||||
stats = await analyseDeps.client.getStats(doc.siteKey, Number(range))
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Auswertung:', err.status || '', err.message)
|
||||
return res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' })
|
||||
}
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
fail(res, err, 'Die Auswertung konnte gerade nicht geladen werden.')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
const { projectId, path, vp } = req.body || {}
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#]/.test(path)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Pfad' })
|
||||
}
|
||||
if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' })
|
||||
const { project, doc } = await ownAnalyseDoc(req, projectId)
|
||||
if (!doc?.siteKey || doc.state !== 'active') {
|
||||
return res.status(409).json({ error: 'Die Website-Analyse ist für dieses Projekt nicht aktiv.' })
|
||||
}
|
||||
const domains = analyseDomains(project)
|
||||
const host = project.liveDomain ? domains[domains.length - 1] : domains[0]
|
||||
const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp })
|
||||
res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}` })
|
||||
} catch (err) {
|
||||
fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -183,12 +183,14 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
})
|
||||
}
|
||||
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date() }) {
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer' }) {
|
||||
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 }
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { config } from '../config.js'
|
||||
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { deleteNewsImage } from './newsImages.js'
|
||||
import { disable } from './siteFeatures.js'
|
||||
import { analyseDeps, analyseDomains } from './analyse.js'
|
||||
import { getStripe } from './stripeBilling.js'
|
||||
import { filesOf, STATUS } from './takeoverCredits.js'
|
||||
|
||||
@@ -220,18 +219,8 @@ export async function applyDeletions(plan) {
|
||||
}
|
||||
for (const id of entry.featureIds) {
|
||||
const off = await attempt('portalFeatures', id, async () => {
|
||||
const feature = await getDocument(FEATURES(), id).catch(() => null)
|
||||
// disable() meldet bei Analyse auch den Sammeldienst ab (best effort)
|
||||
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||
// Analyse: auch den Sammeldienst abschalten (best effort, die Daten loescht dessen 60-Tage-Job)
|
||||
if (feature?.featureKey === 'analyse' && feature.siteKey) {
|
||||
try {
|
||||
// der Sammeldienst verlangt mindestens eine Domain, auch beim Abschalten
|
||||
const project = await getDocument(config.collections.websiteProjects, feature.projectId)
|
||||
await analyseDeps.client.putSite(feature.siteKey, { projectId: feature.projectId, domains: analyseDomains(project), active: false })
|
||||
} catch (err) {
|
||||
console.warn(`[retention] Sammeldienst für Analyse ${id} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (off) deleted.features += 1
|
||||
|
||||
@@ -293,11 +293,25 @@ export async function activate(docId, { by, adminNote } = {}) {
|
||||
|
||||
export async function disable(docId, { by, adminNote } = {}) {
|
||||
const note = adminNote ?? byNote(by, 'Abgeschaltet')
|
||||
return updateDocument(config.collections.portalFeatures, docId, {
|
||||
const doc = await updateDocument(config.collections.portalFeatures, docId, {
|
||||
...stateFields('disabled'),
|
||||
disabledAt: new Date().toISOString(),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
|
||||
return doc
|
||||
}
|
||||
|
||||
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. 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 })
|
||||
} catch (err) {
|
||||
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */
|
||||
|
||||
Reference in new issue
Block a user