Kundenansicht P5: Analyse im Abo-Modell (Hosting, Zielgruppe erzwungen, Nachweis, Tagesprüfung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23252df359
commit
57d5db2344
4 files changed
+271
-21
No files matched your search
+36
-11
@@ -26,16 +26,17 @@ import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../ser
|
||||
import { starteBehalten } from '../services/zeiten/behalten.js'
|
||||
import { aboMemo } from '../services/aboStatus.js'
|
||||
import { aboProdukt } from '../services/aboFunktionen.js'
|
||||
import { analyseUnterlagen } from '../services/aboUnterlagen.js'
|
||||
import { anfrageStatusJeProjekt, katalogFuerKunde } from '../services/aboKatalog.js'
|
||||
import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js'
|
||||
import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js'
|
||||
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
||||
import { aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { abowegAnalyse, aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
|
||||
import {
|
||||
ELIGIBILITY_MESSAGES,
|
||||
activateAnalyse,
|
||||
eligibilityMessage,
|
||||
analyseDeps,
|
||||
analyseHosts,
|
||||
isDevPreview,
|
||||
@@ -60,11 +61,11 @@ function fail(res, err, 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) {
|
||||
async function analyseView(customerId, projectId, { aboModell = false } = {}) {
|
||||
if (!config.analyse.enabled) return { comingSoon: true }
|
||||
const [doc, eligibility, controller] = await Promise.all([
|
||||
getFeatureDoc(customerId, projectId, 'analyse'),
|
||||
analyseEligibility(customerId, projectId),
|
||||
analyseEligibility(customerId, projectId, { aboModell }),
|
||||
controllerView(customerId),
|
||||
])
|
||||
let devMode = false
|
||||
@@ -78,9 +79,10 @@ async function analyseView(customerId, projectId) {
|
||||
accentColor: doc?.accentColor || '#111111',
|
||||
eligible: eligibility.ok,
|
||||
reason: eligibility.reason || null,
|
||||
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
legal: legalPaths(config.analyse.avvVersion),
|
||||
reasonText: eligibility.reason ? eligibilityMessage(eligibility.reason, { aboModell }) : null,
|
||||
// Neue Ansicht: Fassung und Adressen aus derselben Quelle wie der Nachweis (die Oberflaeche zeigt und sendet, was der Server prueft)
|
||||
avvVersion: aboModell ? analyseUnterlagen().avvVersion : config.analyse.avvVersion,
|
||||
legal: aboModell ? analyseUnterlagen().legal : legalPaths(config.analyse.avvVersion),
|
||||
controller,
|
||||
activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null,
|
||||
}
|
||||
@@ -215,10 +217,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
const projects = await listCustomerFeatures(customerId, { docs })
|
||||
const zeitenAn = zeitenFuerKunde(req.session.email)
|
||||
const jetzt = einschaltenDeps.jetzt()
|
||||
const neu = neueAnsicht(req)
|
||||
// Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
p.analyse = await analyseView(customerId, p.projectId).catch((err) => {
|
||||
p.analyse = await analyseView(customerId, p.projectId, { aboModell: neu }).catch((err) => {
|
||||
console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message)
|
||||
return { state: 'unknown' }
|
||||
})
|
||||
@@ -252,7 +255,7 @@ router.get('/', requireSession, async (req, res) => {
|
||||
}
|
||||
// Kundenansicht neu (V5): Abo-Modell mit Kundenart, Abo-Stand, Katalog und Anfragestatus von Webshop und Buchungskalender je Projekt.
|
||||
// Ohne neue Ansicht bleibt die Antwort wie sie war. Ein Lesefehler beim Abo-Stand ist ein Fehler der Liste (nie ein erfundenes "kein Abo").
|
||||
if (neueAnsicht(req)) {
|
||||
if (neu) {
|
||||
const [kunde, abo, katalog, anfrageStatus] = await Promise.all([
|
||||
kundenDokument(customerId),
|
||||
aboProdukt(customerId, { now: jetzt }),
|
||||
@@ -601,14 +604,36 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' })
|
||||
}
|
||||
const { projectId, accentColor, consent } = req.body || {}
|
||||
await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true })
|
||||
// Neue Ansicht (Abo-Modell): die Zielgruppen-Aussage (keine Website fuer Kinder unter 16) ist serverseitig erzwungen, nicht nur ein
|
||||
// Haekchen im Browser; vor jedem Schreiben. Die alte Ansicht bleibt unveraendert.
|
||||
const neu = neueAnsicht(req)
|
||||
if (neu && req.body?.audienceConfirmed !== true) {
|
||||
throw bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')
|
||||
}
|
||||
const customerId = getSessionCustomerId(req)
|
||||
await assertOwnProject(customerId, analyseProjectId(projectId), { forNewOrder: true })
|
||||
// Nachweis der Erklaerung (featureConsents) mit der Kundenart des Dokuments, nie aus dem Body; entsteht nur bei einer neuen Aktivierung
|
||||
const abo = neu
|
||||
? {
|
||||
aboModell: true,
|
||||
nachweis: async () =>
|
||||
speichereNachweisAusAnfrage(req, {
|
||||
feature: abowegAnalyse(),
|
||||
customerId,
|
||||
projectId,
|
||||
klickAt: einschaltenDeps.jetzt().toISOString(),
|
||||
kundenart: kundenartWirksam(await kundenDokument(customerId)),
|
||||
}),
|
||||
}
|
||||
: {}
|
||||
const { doc } = await activateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
customerId,
|
||||
projectId: analyseProjectId(projectId),
|
||||
accentColor,
|
||||
consent,
|
||||
by: 'customer',
|
||||
actor: actingPerson(req.session),
|
||||
...abo,
|
||||
})
|
||||
res.json({ ok: true, state: doc.state })
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { config } from '../config.js'
|
||||
import { getDocument } from './appwriteAdmin.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
|
||||
import { listContractEvents, openWithdrawals } from './contractEvents.js'
|
||||
import { neueAnsichtFuerKunde } from './kundenansicht/schalter.js'
|
||||
|
||||
/**
|
||||
* Abo-Zuordnung der Funktionen (V5, Spec E3/E8): Welches Abo deckt welche Funktion? Ein Lesepfad (lies) fuer alle
|
||||
@@ -87,3 +90,20 @@ export async function aboLaeuftFuer(customerId, key) {
|
||||
const l = await lies(customerId, new Date())
|
||||
return l.laeuft && ENTHALTEN_IN[key].includes(l.produkt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoert dieser Kunde zum Abo-Modell (sieht er die neue Ansicht)? Fuer Jobs und Dienste ohne Cookie (Tagespruefung der Analyse,
|
||||
* Team-Aktivierung): liest das Kundendokument (kein Dokument: false) und fragt neueAnsichtFuerKunde({ customerId, email }). Nie
|
||||
* neueAnsichtFuerSession: eine Sitzung braucht dort auch appwriteUserId, die ein Job nicht hat. Lesefehler werden weitergereicht.
|
||||
*/
|
||||
export async function aboModellFuerKunde(customerId) {
|
||||
if (typeof customerId !== 'string' || customerId === '') return false
|
||||
let kunde
|
||||
try {
|
||||
kunde = await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
throw err
|
||||
}
|
||||
return neueAnsichtFuerKunde({ customerId, email: kunde.email })
|
||||
}
|
||||
+31
-10
@@ -9,6 +9,8 @@ import { sendCustomerMail } from './mailer.js'
|
||||
import { triggerRebuild } from './previewRebuild.js'
|
||||
import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js'
|
||||
import { laeuft } from './aboStatus.js'
|
||||
import { aboDeckt, aboLaeuftFuer, aboModellFuerKunde } from './aboFunktionen.js'
|
||||
import { consentAboAnalyse } from './featureRegistry.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { legalPaths, legalPdfAttachments } from './analyseLegal.js'
|
||||
@@ -40,6 +42,10 @@ export const ELIGIBILITY_MESSAGES = {
|
||||
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 }
|
||||
/** Im Abo-Modell (neue Ansicht) reicht auch ein Hosting-Abo: nur dieser Text weicht ab. */
|
||||
export const ELIGIBILITY_MESSAGES_ABO = { kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.' }
|
||||
export const eligibilityMessage = (reason, { aboModell = false } = {}) =>
|
||||
(aboModell && ELIGIBILITY_MESSAGES_ABO[reason]) || ELIGIBILITY_MESSAGES[reason]
|
||||
|
||||
/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */
|
||||
const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false })
|
||||
@@ -53,11 +59,16 @@ async function loadOwnProject(customerId, projectId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<{ ok: boolean, reason?: string, project?: object }>} */
|
||||
export async function analyseEligibility(customerId, projectId) {
|
||||
/**
|
||||
* @param {{ aboModell?: boolean }} [optionen] aboModell (neue Ansicht): ein laufendes Hosting- oder Wartungs-Abo genuegt (aboDeckt:
|
||||
* nicht gekuendigt, aktiv; in der Probezeit gilt weiter kein_abo); sonst die strenge Regel "Wartungs-Abo laeuft".
|
||||
* @returns {Promise<{ ok: boolean, reason?: string, project?: object }>}
|
||||
*/
|
||||
export async function analyseEligibility(customerId, projectId, { aboModell = false } = {}) {
|
||||
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' }
|
||||
const aboOk = aboModell ? (await aboDeckt(customerId, FEATURE_KEY)).ok === true : await hasWartungsAbo(customerId)
|
||||
if (!aboOk) 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 }
|
||||
@@ -278,12 +289,18 @@ async function sendK18(doc, project) {
|
||||
return doc
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) {
|
||||
/**
|
||||
* Analyse einschalten. aboModell (neue Ansicht, nur durch den Kunden): ein Hosting- oder Wartungs-Abo genuegt, die erwartete Fassung
|
||||
* ist die der Abo-Erklaerung (consentAboAnalyse().avvVersion). nachweis: async () => void, laeuft im selben try wie recordConsent, nach
|
||||
* dem Dokument und davor; ein Fehler nimmt alles zurueck (kein "on" ohne Nachweis). Beides nur bei einer neuen Aktivierung.
|
||||
*/
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date(), aboModell = false, nachweis = null }) {
|
||||
const viaCustomer = by === 'customer'
|
||||
const avvVersion = aboModell ? consentAboAnalyse().avvVersion : config.analyse.avvVersion
|
||||
let accepted
|
||||
let proof = { date: '', channel: '' }
|
||||
if (viaCustomer) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== avvVersion) {
|
||||
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
|
||||
}
|
||||
// Name und E-Mail der handelnden Person (bei Admin-Sitzung der Admin, siehe Route)
|
||||
@@ -299,8 +316,8 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
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 eligibility = await analyseEligibility(customerId, projectId, { aboModell })
|
||||
if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], eligibilityMessage(eligibility.reason, { aboModell }))
|
||||
const { project } = eligibility
|
||||
if (!analyseDomains(project).length) throw httpError(409, 'Keine gültige Domain für die Analyse')
|
||||
|
||||
@@ -323,14 +340,14 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
projectId,
|
||||
siteKey,
|
||||
event: 'on',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
avvVersion,
|
||||
via: viaCustomer ? 'portal' : 'woms',
|
||||
acceptedAt: at,
|
||||
acceptedBy: accepted,
|
||||
textformDate: proof.date,
|
||||
textformChannel: proof.channel,
|
||||
audienceConfirmed: true, // Kunden-Haken enthaelt die Zielgruppen-Aussage; Mitarbeiter bestaetigen sie getrennt
|
||||
audienceTextVersion: config.analyse.avvVersion,
|
||||
audienceTextVersion: avvVersion,
|
||||
}
|
||||
let updated = false
|
||||
try {
|
||||
@@ -353,7 +370,9 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
publishTicketId: '',
|
||||
})
|
||||
updated = true
|
||||
// Nachweis erst nach dem Dokument: scheitert er, wird alles zurueckgenommen (kein "on" ohne aktives Dokument)
|
||||
// Nachweis der Abo-Erklaerung (featureConsents) und Historie erst nach dem Dokument: scheitert einer, wird alles
|
||||
// zurueckgenommen (kein "on" ohne Nachweis und ohne aktives Dokument)
|
||||
await nachweis?.()
|
||||
await analyseDeps.recordConsent(entry)
|
||||
} catch (err) {
|
||||
await analyseDeps.client
|
||||
@@ -480,6 +499,8 @@ export async function runAboCheck({ now = new Date() } = {}) {
|
||||
try {
|
||||
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
|
||||
if (await hasWartungsAbo(doc.customerId)) continue
|
||||
// Neue Ansicht (Abo-Modell): auch ein laufendes Hosting-Abo haelt die Analyse (gekuendigt zaehlt bis zum Abo-Ende); alle anderen wie bisher
|
||||
if ((await aboModellFuerKunde(doc.customerId)) && (await aboLaeuftFuer(doc.customerId, FEATURE_KEY))) continue
|
||||
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' })
|
||||
if (!changed) continue
|
||||
deactivated.push(doc.$id)
|
||||
|
||||
Reference in new issue
Block a user