feat: Template-Spec v1 Phase C — IF-Renderer + HOSTING/FEATURES + 4 neue DE-Templates
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
- contextBridge.ts: HostingCtx + FeaturesCtx (35 Felder), ~50 neue Platzhalter-Aliases - ruleEngine.ts: buildBoolContext() + applyConditionalBlocks() (IF/IF_NOT/IF_ANY) - ruleEngine.test.ts: 67 Tests (+18 für Phase C), alle grün - page.tsx: IF-Renderer in Pipeline, HOSTING+FEATURES Formular-Sections, erweiterter SDK-Prefill - scripts/apply_templates_023.py: 4 neue DE-Templates (Cookie v2, DSE, AGB, Impressum) - migrations/023_new_templates_de.sql: Dokumentation + Verifikations-Query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,61 @@ export interface ConsentCtx {
|
||||
MARKETING_PARTNERS: string | null
|
||||
}
|
||||
|
||||
export interface HostingCtx {
|
||||
PROVIDER_NAME: string
|
||||
COUNTRY: string
|
||||
CONTRACT_TYPE: string // e.g. "AVV nach Art. 28 DSGVO"
|
||||
}
|
||||
|
||||
export interface FeaturesCtx {
|
||||
// ── Impressum ──────────────────────────────────────────────────────────────
|
||||
HAS_REGULATED_PROFESSION: boolean
|
||||
REGULATED_PROFESSION_TEXT: string
|
||||
HAS_EDITORIAL_RESPONSIBLE: boolean
|
||||
EDITORIAL_RESPONSIBLE_NAME: string
|
||||
EDITORIAL_RESPONSIBLE_ADDRESS: string
|
||||
HAS_DISPUTE_RESOLUTION: boolean
|
||||
DISPUTE_RESOLUTION_TEXT: string
|
||||
|
||||
// ── DSI / Datenschutzerklärung ─────────────────────────────────────────────
|
||||
HAS_THIRD_COUNTRY: boolean
|
||||
TRANSFER_GUARDS: string // e.g. "EU-Standardvertragsklauseln (SCC)"
|
||||
SECURITY_MEASURES_SUMMARY: string // e.g. "TLS, RBAC, Backups, Logging"
|
||||
DATA_SUBJECT_REQUEST_CHANNEL: string // e.g. "per E-Mail an {{CONTACT_EMAIL}}"
|
||||
CONSENT_WITHDRAWAL_PATH: string // e.g. "Footer-Link ›Cookie-Einstellungen‹"
|
||||
|
||||
// ── Service-Features (DSI/AGB) ─────────────────────────────────────────────
|
||||
HAS_NEWSLETTER: boolean
|
||||
NEWSLETTER_PROVIDER_DETAIL: string
|
||||
HAS_ACCOUNT: boolean
|
||||
HAS_PAYMENTS: boolean
|
||||
PAYMENT_PROVIDER_DETAIL: string
|
||||
HAS_SUPPORT: boolean
|
||||
HAS_SOCIAL_MEDIA: boolean
|
||||
SOCIAL_MEDIA_DETAIL: string
|
||||
|
||||
// ── Tool-Details (DSI + Cookie Banner) ────────────────────────────────────
|
||||
ANALYTICS_TOOLS_DETAIL: string
|
||||
MARKETING_TOOLS_DETAIL: string
|
||||
CMP_NAME: string
|
||||
CMP_LOGS_CONSENTS: boolean
|
||||
HAS_FUNCTIONAL_COOKIES: boolean
|
||||
|
||||
// ── AGB (SaaS) ────────────────────────────────────────────────────────────
|
||||
HAS_PAID_PLANS: boolean
|
||||
PRICES_TEXT: string
|
||||
PAYMENT_TERMS_TEXT: string
|
||||
CONTRACT_TERM_TEXT: string
|
||||
HAS_SLA: boolean
|
||||
SLA_URL: string
|
||||
HAS_EXPORT_POLICY: boolean
|
||||
EXPORT_POLICY_TEXT: string
|
||||
LIMITATION_CAP_TEXT: string
|
||||
HAS_WITHDRAWAL: boolean
|
||||
CONSUMER_WITHDRAWAL_TEXT: string
|
||||
SUPPORT_CHANNELS_TEXT: string
|
||||
}
|
||||
|
||||
export interface TemplateContext {
|
||||
PROVIDER: ProviderCtx
|
||||
CUSTOMER: CustomerCtx
|
||||
@@ -123,6 +178,8 @@ export interface TemplateContext {
|
||||
SECURITY: SecurityCtx
|
||||
NDA: NDACtx
|
||||
CONSENT: ConsentCtx
|
||||
HOSTING: HostingCtx
|
||||
FEATURES: FeaturesCtx
|
||||
}
|
||||
|
||||
export interface ComputedFlags {
|
||||
@@ -180,6 +237,32 @@ export const EMPTY_CONTEXT: TemplateContext = {
|
||||
},
|
||||
NDA: { PURPOSE: '', DURATION_YEARS: 5, PENALTY_AMOUNT_EUR: null },
|
||||
CONSENT: { WEBSITE_NAME: '', ANALYTICS_TOOLS: null, MARKETING_PARTNERS: null },
|
||||
HOSTING: {
|
||||
PROVIDER_NAME: '', COUNTRY: 'Deutschland', CONTRACT_TYPE: 'AVV nach Art. 28 DSGVO',
|
||||
},
|
||||
FEATURES: {
|
||||
// Impressum
|
||||
HAS_REGULATED_PROFESSION: false, REGULATED_PROFESSION_TEXT: '',
|
||||
HAS_EDITORIAL_RESPONSIBLE: false, EDITORIAL_RESPONSIBLE_NAME: '', EDITORIAL_RESPONSIBLE_ADDRESS: '',
|
||||
HAS_DISPUTE_RESOLUTION: false, DISPUTE_RESOLUTION_TEXT: '',
|
||||
// DSI
|
||||
HAS_THIRD_COUNTRY: false, TRANSFER_GUARDS: 'EU-Standardvertragsklauseln (SCC)',
|
||||
SECURITY_MEASURES_SUMMARY: 'TLS 1.3, RBAC, Verschlüsselung at rest, regelmäßige Backups, Logging',
|
||||
DATA_SUBJECT_REQUEST_CHANNEL: 'per E-Mail', CONSENT_WITHDRAWAL_PATH: 'Footer-Link „Cookie-Einstellungen"',
|
||||
// Service features
|
||||
HAS_NEWSLETTER: false, NEWSLETTER_PROVIDER_DETAIL: '',
|
||||
HAS_ACCOUNT: false, HAS_PAYMENTS: false, PAYMENT_PROVIDER_DETAIL: '',
|
||||
HAS_SUPPORT: false, HAS_SOCIAL_MEDIA: false, SOCIAL_MEDIA_DETAIL: '',
|
||||
// Tools
|
||||
ANALYTICS_TOOLS_DETAIL: '', MARKETING_TOOLS_DETAIL: '',
|
||||
CMP_NAME: '', CMP_LOGS_CONSENTS: false, HAS_FUNCTIONAL_COOKIES: false,
|
||||
// AGB
|
||||
HAS_PAID_PLANS: false, PRICES_TEXT: '', PAYMENT_TERMS_TEXT: '',
|
||||
CONTRACT_TERM_TEXT: '', HAS_SLA: false, SLA_URL: '',
|
||||
HAS_EXPORT_POLICY: false, EXPORT_POLICY_TEXT: '',
|
||||
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
||||
SUPPORT_CHANNELS_TEXT: '',
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -213,6 +296,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
const sec = ctx.SECURITY
|
||||
const nda = ctx.NDA
|
||||
const con = ctx.CONSENT
|
||||
const h = ctx.HOSTING
|
||||
const f = ctx.FEATURES
|
||||
|
||||
const address = providerAddress(p)
|
||||
|
||||
@@ -298,6 +383,57 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
'{{WEBSITE_NAME}}': con.WEBSITE_NAME || str(p.WEBSITE_URL),
|
||||
'{{ANALYTICS_TOOLS}}': str(con.ANALYTICS_TOOLS),
|
||||
'{{MARKETING_PARTNERS}}': str(con.MARKETING_PARTNERS),
|
||||
|
||||
// --- ALIASES (canonical names from new templates) ---
|
||||
'{{COMPANY_LEGAL_NAME}}': str(p.LEGAL_NAME),
|
||||
'{{COMPANY_LEGAL_FORM}}': str(p.LEGAL_FORM),
|
||||
'{{COMPANY_ADDRESS_LINE}}': str(p.ADDRESS_LINE),
|
||||
'{{COMPANY_POSTAL_CODE}}': str(p.POSTAL_CODE),
|
||||
'{{COMPANY_CITY}}': str(p.CITY),
|
||||
'{{COMPANY_COUNTRY}}': str(p.COUNTRY),
|
||||
'{{COMPANY_ADDRESS_FULL}}': [p.ADDRESS_LINE, [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '), p.COUNTRY].filter(Boolean).join(', '),
|
||||
'{{WEBSITE_URL}}': str(p.WEBSITE_URL),
|
||||
'{{CONTACT_PHONE}}': str(p.PHONE),
|
||||
'{{REPRESENTED_BY_NAME}}': str(p.CEO_NAME),
|
||||
'{{REGISTER_COURT}}': str(p.REGISTER_COURT),
|
||||
'{{REGISTER_NUMBER}}': str(p.REGISTER_NUMBER),
|
||||
'{{VAT_ID}}': str(p.VAT_ID),
|
||||
'{{SERVICE_DESCRIPTION_SHORT}}': str(s.DESCRIPTION),
|
||||
'{{NOTICE_PERIOD_DAYS}}': str(s.TERMINATION_NOTICE_DAYS),
|
||||
'{{ANALYTICS_TOOLS_LIST}}': str(con.ANALYTICS_TOOLS),
|
||||
'{{MARKETING_PARTNERS_LIST}}': str(con.MARKETING_PARTNERS),
|
||||
'{{DATA_PROCESSING_LOCATIONS}}': str(s.DATA_LOCATION),
|
||||
'{{SUPERVISORY_AUTHORITY_NAME}}': str(prv.SUPERVISORY_AUTHORITY_NAME),
|
||||
'{{SUPERVISORY_AUTHORITY_ADDRESS}}': str(prv.SUPERVISORY_AUTHORITY_ADDRESS),
|
||||
|
||||
// --- HOSTING ---
|
||||
'{{HOSTING_PROVIDER_NAME}}': str(h.PROVIDER_NAME),
|
||||
'{{HOSTING_PROVIDER_COUNTRY}}': str(h.COUNTRY),
|
||||
'{{HOSTING_PROVIDER_CONTRACT_TYPE}}': str(h.CONTRACT_TYPE),
|
||||
|
||||
// --- FEATURES (text fields) ---
|
||||
'{{CONSENT_WITHDRAWAL_PATH}}': str(f.CONSENT_WITHDRAWAL_PATH),
|
||||
'{{SECURITY_MEASURES_SUMMARY}}': str(f.SECURITY_MEASURES_SUMMARY),
|
||||
'{{DATA_SUBJECT_REQUEST_CHANNEL}}': str(f.DATA_SUBJECT_REQUEST_CHANNEL),
|
||||
'{{TRANSFER_GUARDS}}': str(f.TRANSFER_GUARDS),
|
||||
'{{REGULATED_PROFESSION_TEXT}}': str(f.REGULATED_PROFESSION_TEXT),
|
||||
'{{EDITORIAL_RESPONSIBLE_NAME}}': str(f.EDITORIAL_RESPONSIBLE_NAME),
|
||||
'{{EDITORIAL_RESPONSIBLE_ADDRESS}}': str(f.EDITORIAL_RESPONSIBLE_ADDRESS),
|
||||
'{{DISPUTE_RESOLUTION_TEXT}}': str(f.DISPUTE_RESOLUTION_TEXT),
|
||||
'{{NEWSLETTER_PROVIDER_DETAIL}}': str(f.NEWSLETTER_PROVIDER_DETAIL),
|
||||
'{{PAYMENT_PROVIDER_DETAIL}}': str(f.PAYMENT_PROVIDER_DETAIL),
|
||||
'{{SOCIAL_MEDIA_DETAIL}}': str(f.SOCIAL_MEDIA_DETAIL),
|
||||
'{{ANALYTICS_TOOLS_DETAIL}}': str(f.ANALYTICS_TOOLS_DETAIL),
|
||||
'{{MARKETING_TOOLS_DETAIL}}': str(f.MARKETING_TOOLS_DETAIL),
|
||||
'{{CMP_NAME}}': str(f.CMP_NAME),
|
||||
'{{PRICES_TEXT}}': str(f.PRICES_TEXT),
|
||||
'{{PAYMENT_TERMS_TEXT}}': str(f.PAYMENT_TERMS_TEXT),
|
||||
'{{CONTRACT_TERM_TEXT}}': str(f.CONTRACT_TERM_TEXT),
|
||||
'{{SLA_URL}}': str(f.SLA_URL),
|
||||
'{{EXPORT_POLICY_TEXT}}': str(f.EXPORT_POLICY_TEXT),
|
||||
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
||||
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
||||
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,16 +459,18 @@ export function computeFlags(ctx: TemplateContext): ComputedFlags {
|
||||
|
||||
/** All placeholder patterns covered by each context section */
|
||||
const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
|
||||
PROVIDER: ['{{COMPANY_NAME}}', '{{PROVIDER_NAME}}', '{{DISCLOSING_PARTY}}', '{{SERVICE_PROVIDER}}', '{{COMPANY_ADDRESS}}', '{{PROVIDER_ADDRESS}}', '{{CONTACT_EMAIL}}', '{{PROVIDER_EMAIL}}', '{{ABUSE_EMAIL}}', '{{REPORT_EMAIL}}'],
|
||||
PROVIDER: ['{{COMPANY_NAME}}', '{{PROVIDER_NAME}}', '{{DISCLOSING_PARTY}}', '{{SERVICE_PROVIDER}}', '{{COMPANY_ADDRESS}}', '{{PROVIDER_ADDRESS}}', '{{CONTACT_EMAIL}}', '{{PROVIDER_EMAIL}}', '{{ABUSE_EMAIL}}', '{{REPORT_EMAIL}}', '{{COMPANY_LEGAL_NAME}}', '{{COMPANY_LEGAL_FORM}}', '{{COMPANY_ADDRESS_LINE}}', '{{COMPANY_POSTAL_CODE}}', '{{COMPANY_CITY}}', '{{COMPANY_COUNTRY}}', '{{COMPANY_ADDRESS_FULL}}', '{{WEBSITE_URL}}', '{{CONTACT_PHONE}}', '{{REPRESENTED_BY_NAME}}', '{{REGISTER_COURT}}', '{{REGISTER_NUMBER}}', '{{VAT_ID}}'],
|
||||
CUSTOMER: ['{{CUSTOMER_NAME}}', '{{RECEIVING_PARTY}}', '{{CUSTOMER}}'],
|
||||
SERVICE: ['{{SERVICE_NAME}}', '{{PLATFORM_NAME}}', '{{SERVICE_DESCRIPTION}}', '{{SERVICE_MODEL}}', '{{SERVICE_TIER}}', '{{DATA_LOCATION}}', '{{EXPORT_FORMATS}}', '{{EXPORT_WINDOW_DAYS}}', '{{MIN_TERM_MONTHS}}', '{{TERMINATION_NOTICE_DAYS}}'],
|
||||
SERVICE: ['{{SERVICE_NAME}}', '{{PLATFORM_NAME}}', '{{SERVICE_DESCRIPTION}}', '{{SERVICE_MODEL}}', '{{SERVICE_TIER}}', '{{DATA_LOCATION}}', '{{EXPORT_FORMATS}}', '{{EXPORT_WINDOW_DAYS}}', '{{MIN_TERM_MONTHS}}', '{{TERMINATION_NOTICE_DAYS}}', '{{SERVICE_DESCRIPTION_SHORT}}', '{{NOTICE_PERIOD_DAYS}}', '{{DATA_PROCESSING_LOCATIONS}}'],
|
||||
LEGAL: ['{{GOVERNING_LAW}}', '{{JURISDICTION_CITY}}', '{{VERSION_DATE}}', '{{EFFECTIVE_DATE}}', '{{START_DATE}}'],
|
||||
PRIVACY: ['{{PRIVACY_CONTACT_EMAIL}}', '{{DPO_NAME}}', '{{DPO_EMAIL}}', '{{COPYRIGHT_CONTACT_NAME}}', '{{COPYRIGHT_EMAIL}}', '{{PRIVACY_POLICY_URL}}', '{{COOKIE_POLICY_URL}}', '{{ANALYTICS_RETENTION_MONTHS}}', '{{DATA_TRANSFER_THIRD_COUNTRIES}}'],
|
||||
PRIVACY: ['{{PRIVACY_CONTACT_EMAIL}}', '{{DPO_NAME}}', '{{DPO_EMAIL}}', '{{COPYRIGHT_CONTACT_NAME}}', '{{COPYRIGHT_EMAIL}}', '{{PRIVACY_POLICY_URL}}', '{{COOKIE_POLICY_URL}}', '{{ANALYTICS_RETENTION_MONTHS}}', '{{DATA_TRANSFER_THIRD_COUNTRIES}}', '{{SUPERVISORY_AUTHORITY_NAME}}', '{{SUPERVISORY_AUTHORITY_ADDRESS}}'],
|
||||
SLA: ['{{AVAILABILITY_PERCENT}}', '{{MAINTENANCE_NOTICE_HOURS}}', '{{SUPPORT_EMAIL}}', '{{SUPPORT_PHONE}}', '{{SUPPORT_HOURS}}', '{{RESPONSE_CRITICAL_H}}', '{{RESOLUTION_CRITICAL_H}}', '{{RESPONSE_HIGH_H}}', '{{RESOLUTION_HIGH_H}}', '{{RESPONSE_MEDIUM_H}}', '{{RESOLUTION_MEDIUM_H}}', '{{RESPONSE_LOW_H}}'],
|
||||
PAYMENTS: ['{{MONTHLY_FEE}}', '{{PAYMENT_DUE_DAY}}', '{{PAYMENT_METHOD}}', '{{PAYMENT_DAYS}}'],
|
||||
SECURITY: ['{{INCIDENT_NOTICE_HOURS}}', '{{LOG_RETENTION_DAYS}}', '{{SECURITY_LOG_RETENTION_DAYS}}'],
|
||||
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}'],
|
||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
||||
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
||||
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from './contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
buildBoolContext, applyConditionalBlocks,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from './ruleEngine'
|
||||
|
||||
@@ -59,6 +60,8 @@ const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
||||
SECURITY: 'Sicherheit & Logs',
|
||||
NDA: 'Geheimhaltung (NDA)',
|
||||
CONSENT: 'Cookie / Einwilligung',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
}
|
||||
|
||||
type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||
@@ -156,6 +159,55 @@ const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
||||
{ key: 'ANALYTICS_TOOLS', label: 'Analytics-Tools (leer = kein Block)', nullable: true },
|
||||
{ key: 'MARKETING_PARTNERS', label: 'Marketing-Partner (leer = kein Block)', nullable: true },
|
||||
],
|
||||
HOSTING: [
|
||||
{ key: 'PROVIDER_NAME', label: 'Hosting-Anbieter' },
|
||||
{ key: 'COUNTRY', label: 'Hosting-Land' },
|
||||
{ key: 'CONTRACT_TYPE', label: 'Vertragstyp (z. B. AVV nach Art. 28 DSGVO)' },
|
||||
],
|
||||
FEATURES: [
|
||||
// ── DSI / Cookie ─────────────────────────────────────────────────────────
|
||||
{ key: 'CONSENT_WITHDRAWAL_PATH', label: 'Einwilligungs-Widerrufspfad' },
|
||||
{ key: 'SECURITY_MEASURES_SUMMARY', label: 'Sicherheitsmaßnahmen (kurz)' },
|
||||
{ key: 'DATA_SUBJECT_REQUEST_CHANNEL', label: 'Kanal für Betroffenenanfragen' },
|
||||
{ key: 'HAS_THIRD_COUNTRY', label: 'Drittlandübermittlung möglich', type: 'boolean' },
|
||||
{ key: 'TRANSFER_GUARDS', label: 'Garantien (z. B. SCC)' },
|
||||
// ── Cookie/Consent ───────────────────────────────────────────────────────
|
||||
{ key: 'HAS_FUNCTIONAL_COOKIES', label: 'Funktionale Cookies aktiviert', type: 'boolean' },
|
||||
{ key: 'CMP_NAME', label: 'Consent-Manager-Name (optional)' },
|
||||
{ key: 'CMP_LOGS_CONSENTS', label: 'Consent-Protokollierung aktiv', type: 'boolean' },
|
||||
{ key: 'ANALYTICS_TOOLS_DETAIL', label: 'Analyse-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'MARKETING_TOOLS_DETAIL', label: 'Marketing-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
// ── Service-Features ─────────────────────────────────────────────────────
|
||||
{ key: 'HAS_ACCOUNT', label: 'Nutzerkonten vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_PAYMENTS', label: 'Zahlungsabwicklung vorhanden', type: 'boolean' },
|
||||
{ key: 'PAYMENT_PROVIDER_DETAIL', label: 'Zahlungsanbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SUPPORT', label: 'Support-Funktion vorhanden', type: 'boolean' },
|
||||
{ key: 'SUPPORT_CHANNELS_TEXT', label: 'Support-Kanäle / Zeiten' },
|
||||
{ key: 'HAS_NEWSLETTER', label: 'Newsletter vorhanden', type: 'boolean' },
|
||||
{ key: 'NEWSLETTER_PROVIDER_DETAIL', label: 'Newsletter-Anbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SOCIAL_MEDIA', label: 'Social-Media-Präsenz', type: 'boolean' },
|
||||
{ key: 'SOCIAL_MEDIA_DETAIL', label: 'Social-Media-Details', type: 'textarea', span: true },
|
||||
// ── AGB ──────────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_PAID_PLANS', label: 'Kostenpflichtige Pläne', type: 'boolean' },
|
||||
{ key: 'PRICES_TEXT', label: 'Preise (Text/Link)', type: 'textarea', span: true },
|
||||
{ key: 'PAYMENT_TERMS_TEXT', label: 'Zahlungsbedingungen', type: 'textarea', span: true },
|
||||
{ key: 'CONTRACT_TERM_TEXT', label: 'Laufzeit & Kündigung', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SLA', label: 'SLA vorhanden', type: 'boolean' },
|
||||
{ key: 'SLA_URL', label: 'SLA-URL' },
|
||||
{ key: 'HAS_EXPORT_POLICY', label: 'Datenexport/Löschung geregelt', type: 'boolean' },
|
||||
{ key: 'EXPORT_POLICY_TEXT', label: 'Datenexport-Regelung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_WITHDRAWAL', label: 'Widerrufsrecht (B2C digital)', type: 'boolean' },
|
||||
{ key: 'CONSUMER_WITHDRAWAL_TEXT', label: 'Widerrufsbelehrung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'LIMITATION_CAP_TEXT', label: 'Haftungsdeckel B2B (Text)' },
|
||||
// ── Impressum ────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_REGULATED_PROFESSION', label: 'Reglementierter Beruf', type: 'boolean' },
|
||||
{ key: 'REGULATED_PROFESSION_TEXT', label: 'Berufsrecht-Text', type: 'textarea', span: true },
|
||||
{ key: 'HAS_EDITORIAL_RESPONSIBLE', label: 'V.i.S.d.P. (redaktionell)', type: 'boolean' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_NAME', label: 'V.i.S.d.P. Name' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -401,16 +453,25 @@ function GeneratorSection({
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
// Boolean context for {{#IF}} rendering
|
||||
const boolCtx = useMemo(
|
||||
() => ruleResult ? buildBoolContext(ruleResult.contextAfterDefaults, ruleResult.computedFlags) : {},
|
||||
[ruleResult]
|
||||
)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
// Apply block removal BEFORE placeholder substitution
|
||||
// 1. Remove ruleset-driven blocks ([BLOCK:ID])
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
// 2. Evaluate {{#IF}} / {{#IF_NOT}} / {{#IF_ANY}} directives
|
||||
content = applyConditionalBlocks(content, boolCtx)
|
||||
// 3. Substitute placeholders
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues, ruleResult])
|
||||
}, [template.text, allPlaceholderValues, ruleResult, boolCtx])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
@@ -776,22 +837,42 @@ function DocumentGeneratorPageInner() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Pre-fill context from company profile
|
||||
// Pre-fill context from company profile + SDK state
|
||||
useEffect(() => {
|
||||
if (state?.companyProfile) {
|
||||
const profile = state.companyProfile
|
||||
const p = profile as Record<string, string>
|
||||
setContext((prev) => ({
|
||||
...prev,
|
||||
PROVIDER: {
|
||||
...prev.PROVIDER,
|
||||
LEGAL_NAME: profile.companyName || prev.PROVIDER.LEGAL_NAME,
|
||||
EMAIL: (profile as Record<string, string>).email || prev.PROVIDER.EMAIL,
|
||||
LEGAL_NAME: profile.companyName || prev.PROVIDER.LEGAL_NAME,
|
||||
LEGAL_FORM: p.legalForm || prev.PROVIDER.LEGAL_FORM,
|
||||
ADDRESS_LINE: p.addressLine || prev.PROVIDER.ADDRESS_LINE,
|
||||
POSTAL_CODE: p.postalCode || prev.PROVIDER.POSTAL_CODE,
|
||||
CITY: p.city || prev.PROVIDER.CITY,
|
||||
COUNTRY: p.country || prev.PROVIDER.COUNTRY,
|
||||
EMAIL: p.email || prev.PROVIDER.EMAIL,
|
||||
PHONE: p.phone || prev.PROVIDER.PHONE,
|
||||
WEBSITE_URL: p.websiteUrl || prev.PROVIDER.WEBSITE_URL,
|
||||
CEO_NAME: p.representedByName || p.ceoName || prev.PROVIDER.CEO_NAME,
|
||||
REGISTER_COURT: p.registerCourt || prev.PROVIDER.REGISTER_COURT,
|
||||
REGISTER_NUMBER: p.registerNumber || prev.PROVIDER.REGISTER_NUMBER,
|
||||
VAT_ID: p.vatId || prev.PROVIDER.VAT_ID,
|
||||
},
|
||||
PRIVACY: {
|
||||
...prev.PRIVACY,
|
||||
DPO_NAME: profile.dpoName || prev.PRIVACY.DPO_NAME,
|
||||
DPO_NAME: profile.dpoName || prev.PRIVACY.DPO_NAME,
|
||||
DPO_EMAIL: profile.dpoEmail || prev.PRIVACY.DPO_EMAIL,
|
||||
CONTACT_EMAIL: profile.dpoEmail || prev.PRIVACY.CONTACT_EMAIL,
|
||||
SUPERVISORY_AUTHORITY_NAME: p.supervisoryAuthorityName || prev.PRIVACY.SUPERVISORY_AUTHORITY_NAME,
|
||||
SUPERVISORY_AUTHORITY_ADDRESS: p.supervisoryAuthorityAddress || prev.PRIVACY.SUPERVISORY_AUTHORITY_ADDRESS,
|
||||
},
|
||||
FEATURES: {
|
||||
...prev.FEATURES,
|
||||
DATA_SUBJECT_REQUEST_CHANNEL: p.email
|
||||
? `per E-Mail an ${p.email}`
|
||||
: prev.FEATURES.DATA_SUBJECT_REQUEST_CHANNEL,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
runRuleset,
|
||||
getDocType,
|
||||
applyBlockRemoval,
|
||||
buildBoolContext,
|
||||
applyConditionalBlocks,
|
||||
type RuleInput,
|
||||
} from './ruleEngine'
|
||||
import { EMPTY_CONTEXT } from './contextBridge'
|
||||
@@ -497,3 +499,166 @@ describe('integration', () => {
|
||||
expect(r.computedFlags.HAS_MARKETING).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// buildBoolContext
|
||||
// =============================================================================
|
||||
|
||||
describe('buildBoolContext', () => {
|
||||
it('maps computed flags directly', () => {
|
||||
const ctx = withCtx({
|
||||
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false },
|
||||
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' },
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.IS_B2C).toBe(true)
|
||||
expect(boolCtx.IS_B2B).toBe(false)
|
||||
expect(boolCtx.SERVICE_IS_SAAS).toBe(true)
|
||||
expect(boolCtx.ANALYTICS_ENABLED).toBe(false)
|
||||
})
|
||||
|
||||
it('HAS_REGISTER derived from PROVIDER.REGISTER_COURT', () => {
|
||||
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, REGISTER_COURT: 'AG München' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_REGISTER).toBe(true)
|
||||
})
|
||||
|
||||
it('HAS_REGISTER=false when REGISTER_COURT is empty', () => {
|
||||
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, REGISTER_COURT: '' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_REGISTER).toBe(false)
|
||||
})
|
||||
|
||||
it('HAS_VAT_ID derived from PROVIDER.VAT_ID', () => {
|
||||
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, VAT_ID: 'DE123456789' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_VAT_ID).toBe(true)
|
||||
})
|
||||
|
||||
it('HAS_DPO derived from PRIVACY.DPO_NAME', () => {
|
||||
const ctx = withCtx({ PRIVACY: { ...EMPTY_CONTEXT.PRIVACY, DPO_NAME: 'Max Mustermann' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_DPO).toBe(true)
|
||||
})
|
||||
|
||||
it('FEATURES booleans are passed through', () => {
|
||||
const ctx = withCtx({
|
||||
FEATURES: {
|
||||
...EMPTY_CONTEXT.FEATURES,
|
||||
HAS_NEWSLETTER: true,
|
||||
HAS_ACCOUNT: true,
|
||||
HAS_PAID_PLANS: false,
|
||||
HAS_THIRD_COUNTRY: true,
|
||||
},
|
||||
})
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_NEWSLETTER).toBe(true)
|
||||
expect(boolCtx.HAS_ACCOUNT).toBe(true)
|
||||
expect(boolCtx.HAS_PAID_PLANS).toBe(false)
|
||||
expect(boolCtx.THIRD_COUNTRY_POSSIBLE).toBe(true)
|
||||
})
|
||||
|
||||
it('MARKETING_ENABLED is alias for HAS_MARKETING', () => {
|
||||
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: 'Meta Pixel' } })
|
||||
const r = runRuleset(makeInput({ context: ctx }))
|
||||
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
||||
expect(boolCtx.HAS_MARKETING).toBe(true)
|
||||
expect(boolCtx.MARKETING_ENABLED).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// applyConditionalBlocks
|
||||
// =============================================================================
|
||||
|
||||
describe('applyConditionalBlocks', () => {
|
||||
it('{{#IF}} shows block when condition is true', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF HAS_ANALYTICS}}\nAnalytics section\n{{/IF}}',
|
||||
{ HAS_ANALYTICS: true }
|
||||
)
|
||||
expect(result).toContain('Analytics section')
|
||||
expect(result).not.toContain('{{#IF')
|
||||
})
|
||||
|
||||
it('{{#IF}} hides block when condition is false', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF HAS_ANALYTICS}}\nAnalytics section\n{{/IF}}',
|
||||
{ HAS_ANALYTICS: false }
|
||||
)
|
||||
expect(result).toBe('')
|
||||
expect(result).not.toContain('Analytics section')
|
||||
})
|
||||
|
||||
it('{{#IF_NOT}} shows block when condition is false', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF_NOT HAS_DPO}}\nKein DSB bestellt.\n{{/IF_NOT}}',
|
||||
{ HAS_DPO: false }
|
||||
)
|
||||
expect(result).toContain('Kein DSB bestellt.')
|
||||
})
|
||||
|
||||
it('{{#IF_NOT}} hides block when condition is true', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF_NOT HAS_DPO}}\nKein DSB bestellt.\n{{/IF_NOT}}',
|
||||
{ HAS_DPO: true }
|
||||
)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('{{#IF_ANY}} shows block when any condition is true', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF_ANY HAS_ANALYTICS HAS_MARKETING}}\nTracking aktiv.\n{{/IF_ANY}}',
|
||||
{ HAS_ANALYTICS: false, HAS_MARKETING: true }
|
||||
)
|
||||
expect(result).toContain('Tracking aktiv.')
|
||||
})
|
||||
|
||||
it('{{#IF_ANY}} hides block when all conditions are false', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF_ANY HAS_ANALYTICS HAS_MARKETING}}\nTracking aktiv.\n{{/IF_ANY}}',
|
||||
{ HAS_ANALYTICS: false, HAS_MARKETING: false }
|
||||
)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('processes multiple blocks independently', () => {
|
||||
const content = '{{#IF A}}\nBlock A\n{{/IF}}\n{{#IF_NOT B}}\nBlock B\n{{/IF_NOT}}'
|
||||
const result = applyConditionalBlocks(content, { A: true, B: false })
|
||||
expect(result).toContain('Block A')
|
||||
expect(result).toContain('Block B')
|
||||
})
|
||||
|
||||
it('handles multiline block content', () => {
|
||||
const content = '{{#IF HAS_DPO}}\nName: {{DPO_NAME}}\nEmail: {{DPO_EMAIL}}\n{{/IF}}'
|
||||
const result = applyConditionalBlocks(content, { HAS_DPO: true })
|
||||
expect(result).toContain('Name: {{DPO_NAME}}')
|
||||
expect(result).toContain('Email: {{DPO_EMAIL}}')
|
||||
})
|
||||
|
||||
it('preserves placeholders inside kept blocks for later substitution', () => {
|
||||
const content = '{{#IF HAS_ANALYTICS}}\nTool: {{ANALYTICS_TOOLS_LIST}}\n{{/IF}}'
|
||||
const result = applyConditionalBlocks(content, { HAS_ANALYTICS: true })
|
||||
expect(result).toContain('{{ANALYTICS_TOOLS_LIST}}')
|
||||
})
|
||||
|
||||
it('is a no-op when content has no conditional directives', () => {
|
||||
const plain = '# Impressum\n\n{{COMPANY_LEGAL_NAME}}'
|
||||
expect(applyConditionalBlocks(plain, {})).toBe(plain)
|
||||
})
|
||||
|
||||
it('handles unknown condition as false (not in boolCtx)', () => {
|
||||
const result = applyConditionalBlocks(
|
||||
'{{#IF UNKNOWN_COND}}\nHidden\n{{/IF}}',
|
||||
{}
|
||||
)
|
||||
expect(result).toBe('')
|
||||
expect(result).not.toContain('Hidden')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -359,6 +359,83 @@ export function getDocType(templateType: string, language: string): string {
|
||||
return DOC_TYPE_MAP[key] ?? `${templateType}_${language}`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// buildBoolContext: combines computed flags + derived + FEATURES booleans
|
||||
// =============================================================================
|
||||
|
||||
export function buildBoolContext(ctx: TemplateContext, flags: ComputedFlags): Record<string, boolean> {
|
||||
const f = ctx.FEATURES
|
||||
return {
|
||||
// --- From computed flags ---
|
||||
IS_B2C: flags.IS_B2C,
|
||||
IS_B2B: flags.IS_B2B,
|
||||
SERVICE_IS_SAAS: flags.SERVICE_IS_SAAS,
|
||||
SERVICE_IS_HYBRID: flags.SERVICE_IS_HYBRID,
|
||||
HAS_PENALTY: flags.HAS_PENALTY,
|
||||
HAS_ANALYTICS: flags.HAS_ANALYTICS,
|
||||
ANALYTICS_ENABLED: flags.HAS_ANALYTICS, // alias used in cookie banner template
|
||||
HAS_MARKETING: flags.HAS_MARKETING,
|
||||
MARKETING_ENABLED: flags.HAS_MARKETING, // alias
|
||||
|
||||
// --- Derived from PROVIDER ---
|
||||
HAS_REGISTER: !!(ctx.PROVIDER.REGISTER_COURT),
|
||||
HAS_VAT_ID: !!(ctx.PROVIDER.VAT_ID),
|
||||
CONTACT_PHONE: !!(ctx.PROVIDER.PHONE),
|
||||
|
||||
// --- Derived from PRIVACY ---
|
||||
HAS_DPO: !!(ctx.PRIVACY.DPO_NAME),
|
||||
|
||||
// --- From FEATURES booleans ---
|
||||
THIRD_COUNTRY_POSSIBLE: f.HAS_THIRD_COUNTRY,
|
||||
HAS_THIRD_COUNTRY: f.HAS_THIRD_COUNTRY,
|
||||
FUNCTIONAL_ENABLED: f.HAS_FUNCTIONAL_COOKIES,
|
||||
HAS_FUNCTIONAL_COOKIES: f.HAS_FUNCTIONAL_COOKIES,
|
||||
CMP_LOGS_CONSENTS: f.CMP_LOGS_CONSENTS,
|
||||
HAS_NEWSLETTER: f.HAS_NEWSLETTER,
|
||||
HAS_ACCOUNT: f.HAS_ACCOUNT,
|
||||
HAS_PAYMENTS: f.HAS_PAYMENTS,
|
||||
HAS_SUPPORT: f.HAS_SUPPORT,
|
||||
HAS_SOCIAL_MEDIA: f.HAS_SOCIAL_MEDIA,
|
||||
HAS_PAID_PLANS: f.HAS_PAID_PLANS,
|
||||
HAS_SLA: f.HAS_SLA,
|
||||
HAS_EXPORT_POLICY: f.HAS_EXPORT_POLICY,
|
||||
HAS_WITHDRAWAL: f.HAS_WITHDRAWAL,
|
||||
HAS_REGULATED_PROFESSION: f.HAS_REGULATED_PROFESSION,
|
||||
HAS_EDITORIAL_RESPONSIBLE: f.HAS_EDITORIAL_RESPONSIBLE,
|
||||
HAS_DISPUTE_RESOLUTION: f.HAS_DISPUTE_RESOLUTION,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// applyConditionalBlocks: processes {{#IF}}/{{#IF_NOT}}/{{#IF_ANY}} directives
|
||||
// Runs BEFORE placeholder substitution.
|
||||
// =============================================================================
|
||||
|
||||
export function applyConditionalBlocks(content: string, boolCtx: Record<string, boolean>): string {
|
||||
let result = content
|
||||
|
||||
// {{#IF_NOT COND}}...{{/IF_NOT}} — process before IF to avoid conflicts
|
||||
result = result.replace(
|
||||
/\{\{#IF_NOT ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF_NOT\}\}/g,
|
||||
(_, cond: string, body: string) => (boolCtx[cond] ? '' : body)
|
||||
)
|
||||
|
||||
// {{#IF_ANY A B C}}...{{/IF_ANY}}
|
||||
result = result.replace(
|
||||
/\{\{#IF_ANY ([A-Z_ ]+)\}\}([\s\S]*?)\{\{\/IF_ANY\}\}/g,
|
||||
(_, conds: string, body: string) =>
|
||||
conds.trim().split(/\s+/).some((c) => boolCtx[c]) ? body : ''
|
||||
)
|
||||
|
||||
// {{#IF COND}}...{{/IF}}
|
||||
result = result.replace(
|
||||
/\{\{#IF ([A-Z_]+)\}\}([\s\S]*?)\{\{\/IF\}\}/g,
|
||||
(_, cond: string, body: string) => (boolCtx[cond] ? body : '')
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// applyBlockRemoval: removes [BLOCK:ID]…[/BLOCK:ID] markers from content
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user