feat: Regulatory News Dashboard — proaktive Compliance-Alerts
Some checks failed
Build + Deploy / build-backend-compliance (push) Successful in 2m43s
Build + Deploy / build-admin-compliance (push) Successful in 1m46s
Build + Deploy / build-ai-sdk (push) Successful in 47s
Build + Deploy / build-developer-portal (push) Successful in 1m0s
Build + Deploy / build-tts (push) Successful in 1m14s
Build + Deploy / build-document-crawler (push) Successful in 37s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 42s
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Successful in 24s
CI / test-python-dsms-gateway (push) Successful in 27s
CI / validate-canonical-controls (push) Successful in 23s
Build + Deploy / trigger-orca (push) Failing after 2h32m34s

Zeigt anstehende regulatorische Fristen im Dashboard an, abgeleitet
aus den bestehenden Obligation v2 JSON-Dateien. Keine neue DB-Tabelle.

Erster News-Eintrag: Widerrufsbutton-Pflicht ab 19.06.2026
(EU-RL 2023/2673, §356a BGB) — eigener Text, keine externe Quelle.

Features:
- Go Service: scannt Obligations nach Fristen, berechnet Urgency
- API: GET /sdk/v1/regulatory-news mit Countdown + Farbcodierung
- Dashboard: RegulatoryNewsFeed Sektion mit Countdown-Badges
- Vorlage: news-Feld in v2 JSON fuer zukuenftige regulatorische Updates
- 11 Tests (Sortierung, Urgency, Deadline-Parsing, Real-File-Test)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 17:43:19 +02:00
parent 55a2cd4a3d
commit 717c31547a
11 changed files with 552 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
const url = `${SDK_URL}/sdk/v1/regulatory-news${qs ? `?${qs}` : ''}`
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
},
})
if (!response.ok) {
return NextResponse.json({ error: 'SDK error' }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch {
return NextResponse.json({ error: 'Connection failed' }, { status: 503 })
}
}

View File

@@ -4,6 +4,7 @@ import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk' import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector' import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
import type { SDKPackageId } from '@/lib/sdk/types' import type { SDKPackageId } from '@/lib/sdk/types'
// ============================================================================= // =============================================================================
@@ -331,6 +332,9 @@ export default function SDKDashboard() {
</div> </div>
)} )}
{/* Regulatory News */}
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
{/* 5 Packages */} {/* 5 Packages */}
<div> <div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>

View File

@@ -0,0 +1,60 @@
'use client'
import Link from 'next/link'
export interface RegulatoryNewsItemData {
id: string
headline: string
summary: string
legal_reference: string
deadline: string
days_remaining: number
urgency: 'critical' | 'high' | 'medium' | 'low'
affected: string
action_required: string
action_link: string
regulation: string
sanctions?: string
}
const URGENCY_STYLES = {
critical: { badge: 'bg-red-100 text-red-700 border-red-200', border: 'border-l-red-500', icon: '🔴' },
high: { badge: 'bg-orange-100 text-orange-700 border-orange-200', border: 'border-l-orange-400', icon: '🟠' },
medium: { badge: 'bg-yellow-100 text-yellow-700 border-yellow-200', border: 'border-l-yellow-400', icon: '🟡' },
low: { badge: 'bg-gray-100 text-gray-600 border-gray-200', border: 'border-l-gray-300', icon: '🔵' },
}
export function RegulatoryNewsCard({ item }: { item: RegulatoryNewsItemData }) {
const style = URGENCY_STYLES[item.urgency] || URGENCY_STYLES.low
return (
<div className={`bg-white border border-gray-200 border-l-4 ${style.border} rounded-lg p-4`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-gray-900">{item.headline}</h4>
<p className="text-xs text-gray-600 mt-1">{item.summary}</p>
<p className="text-xs text-gray-400 mt-1 italic">{item.legal_reference}</p>
{item.sanctions && (
<p className="text-xs text-red-600 mt-1">Sanktionen: {item.sanctions}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${style.badge}`}>
{style.icon} {item.days_remaining} Tage
</span>
<span className="text-xs text-gray-400">
{new Date(item.deadline).toLocaleDateString('de-DE')}
</span>
</div>
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
<span className="text-xs text-gray-400">{item.affected}</span>
{item.action_link && (
<Link href={item.action_link} className="text-xs text-blue-600 hover:underline font-medium">
Massnahme ergreifen
</Link>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import React, { useState, useEffect } from 'react'
import { RegulatoryNewsCard, RegulatoryNewsItemData } from './RegulatoryNewsCard'
interface RegulatoryNewsFeedProps {
businessModel?: string
maxItems?: number
}
export function RegulatoryNewsFeed({ businessModel, maxItems = 3 }: RegulatoryNewsFeedProps) {
const [items, setItems] = useState<RegulatoryNewsItemData[]>([])
const [loading, setLoading] = useState(true)
const [showAll, setShowAll] = useState(false)
useEffect(() => {
const params = new URLSearchParams({ limit: '10', horizon_days: '365' })
if (businessModel) params.set('business_model', businessModel)
fetch(`/api/sdk/v1/regulatory-news?${params}`)
.then(r => r.ok ? r.json() : { items: [] })
.then(data => setItems(data.items || []))
.catch(() => setItems([]))
.finally(() => setLoading(false))
}, [businessModel])
if (loading) return null
if (items.length === 0) return null
const visible = showAll ? items : items.slice(0, maxItems)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span>📢</span> Regulatorische Neuigkeiten
</h2>
{items.length > maxItems && (
<button
onClick={() => setShowAll(!showAll)}
className="text-sm text-blue-600 hover:underline"
>
{showAll ? 'Weniger' : `Alle ${items.length} anzeigen`}
</button>
)}
</div>
<div className="space-y-2">
{visible.map(item => (
<RegulatoryNewsCard key={item.id} item={item} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
"github.com/gin-gonic/gin"
)
// RegulatoryNewsHandlers serves regulatory news from obligation v2 data.
type RegulatoryNewsHandlers struct {
regulations map[string]*ucca.V2RegulationFile
}
// NewRegulatoryNewsHandlers creates a handler backed by pre-loaded regulation data.
func NewRegulatoryNewsHandlers(regs map[string]*ucca.V2RegulationFile) *RegulatoryNewsHandlers {
return &RegulatoryNewsHandlers{regulations: regs}
}
// GetNews returns upcoming regulatory deadlines sorted by urgency.
func (h *RegulatoryNewsHandlers) GetNews(c *gin.Context) {
filter := ucca.RegulatoryNewsFilter{
BusinessModel: c.Query("business_model"),
HorizonDays: parseIntOrDefault(c.Query("horizon_days"), 365),
Limit: parseIntOrDefault(c.Query("limit"), 5),
}
items := ucca.GetRegulatoryNews(h.regulations, filter)
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
func parseIntOrDefault(s string, def int) int {
if s == "" {
return def
}
v, err := strconv.Atoi(s)
if err != nil {
return def
}
return v
}

View File

@@ -134,6 +134,14 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore) ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore) obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
// Regulatory News
allV2Regs, err := ucca.LoadAllV2Regulations()
if err != nil {
log.Printf("WARNING: V2 regulations not loaded: %v", err)
allV2Regs = make(map[string]*ucca.V2RegulationFile)
}
regulatoryNewsHandlers := handlers.NewRegulatoryNewsHandlers(allV2Regs)
// Maximizer // Maximizer
maximizerStore := maximizer.NewStore(pool) maximizerStore := maximizer.NewStore(pool)
maximizerRules, err := maximizer.LoadConstraintRulesFromDefault() maximizerRules, err := maximizer.LoadConstraintRulesFromDefault()
@@ -168,7 +176,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers, uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
roadmapHandlers, workshopHandlers, portfolioHandlers, roadmapHandlers, workshopHandlers, portfolioHandlers,
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler, academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
maximizerHandlers) maximizerHandlers, regulatoryNewsHandlers)
return router return router
} }

View File

@@ -27,6 +27,7 @@ func registerRoutes(
whistleblowerHandlers *handlers.WhistleblowerHandlers, whistleblowerHandlers *handlers.WhistleblowerHandlers,
iaceHandler *handlers.IACEHandler, iaceHandler *handlers.IACEHandler,
maximizerHandlers *handlers.MaximizerHandlers, maximizerHandlers *handlers.MaximizerHandlers,
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
) { ) {
v1 := router.Group("/sdk/v1") v1 := router.Group("/sdk/v1")
{ {
@@ -48,6 +49,7 @@ func registerRoutes(
registerWhistleblowerRoutes(v1, whistleblowerHandlers) registerWhistleblowerRoutes(v1, whistleblowerHandlers)
registerIACERoutes(v1, iaceHandler) registerIACERoutes(v1, iaceHandler)
registerMaximizerRoutes(v1, maximizerHandlers) registerMaximizerRoutes(v1, maximizerHandlers)
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
} }
} }

View File

@@ -0,0 +1,146 @@
package ucca
import (
"fmt"
"sort"
"strings"
"time"
)
// RegulatoryNewsItem is a single news item for dashboard display.
type RegulatoryNewsItem struct {
ID string `json:"id"`
Headline string `json:"headline"`
Summary string `json:"summary"`
LegalReference string `json:"legal_reference"`
Deadline string `json:"deadline"`
DaysRemaining int `json:"days_remaining"`
Urgency string `json:"urgency"` // critical, high, medium, low
Affected string `json:"affected"`
ActionRequired string `json:"action_required"`
ActionLink string `json:"action_link"`
Regulation string `json:"regulation"`
Sanctions string `json:"sanctions,omitempty"`
}
// RegulatoryNewsFilter controls which news items are returned.
type RegulatoryNewsFilter struct {
BusinessModel string `json:"business_model,omitempty"`
HorizonDays int `json:"horizon_days,omitempty"` // default 365
Limit int `json:"limit,omitempty"` // default 5
}
// GetRegulatoryNews scans all v2 obligations for upcoming deadlines
// and returns formatted news items sorted by urgency.
func GetRegulatoryNews(regulations map[string]*V2RegulationFile, filter RegulatoryNewsFilter) []RegulatoryNewsItem {
if filter.HorizonDays <= 0 {
filter.HorizonDays = 365
}
if filter.Limit <= 0 {
filter.Limit = 5
}
today := time.Now().UTC().Truncate(24 * time.Hour)
horizon := today.AddDate(0, 0, filter.HorizonDays)
var items []RegulatoryNewsItem
for _, reg := range regulations {
for _, obl := range reg.Obligations {
deadline, ok := resolveDeadline(obl)
if !ok || deadline.Before(today) || deadline.After(horizon) {
continue
}
days := int(deadline.Sub(today).Hours() / 24)
item := buildNewsItem(obl, reg.Regulation, deadline, days)
items = append(items, item)
}
}
sort.Slice(items, func(i, j int) bool {
return items[i].DaysRemaining < items[j].DaysRemaining
})
if len(items) > filter.Limit {
items = items[:filter.Limit]
}
return items
}
func buildNewsItem(obl V2Obligation, regulation string, deadline time.Time, days int) RegulatoryNewsItem {
item := RegulatoryNewsItem{
ID: obl.ID,
Deadline: deadline.Format("2006-01-02"),
DaysRemaining: days,
Urgency: computeUrgency(days),
Regulation: regulation,
}
// Use hand-crafted news if available
if obl.News != nil {
item.Headline = obl.News.Headline
item.Summary = obl.News.Summary
item.ActionRequired = obl.News.ActionRequired
item.Affected = obl.News.Affected
item.ActionLink = obl.News.ActionLink
} else {
// Auto-generate from obligation data
item.Headline = fmt.Sprintf("%s — ab %s", obl.Title, deadline.Format("02.01.2006"))
item.Summary = obl.Description
item.ActionRequired = "Pruefen Sie die Anforderungen und ergreifen Sie Massnahmen."
item.ActionLink = obl.BreakpilotFeature
}
item.LegalReference = formatLegalReference(obl.LegalBasis)
if obl.Sanctions != nil {
item.Sanctions = obl.Sanctions.MaxFine
}
return item
}
func resolveDeadline(obl V2Obligation) (time.Time, bool) {
// Check explicit deadline.date first
if obl.Deadline != nil && obl.Deadline.Date != "" {
t, err := time.Parse("2006-01-02", obl.Deadline.Date)
if err == nil {
return t, true
}
}
// Fallback to valid_from
if obl.ValidFrom != "" {
t, err := time.Parse("2006-01-02", obl.ValidFrom)
if err == nil {
return t, true
}
}
return time.Time{}, false
}
func computeUrgency(daysRemaining int) string {
switch {
case daysRemaining <= 30:
return "critical"
case daysRemaining <= 90:
return "high"
case daysRemaining <= 180:
return "medium"
default:
return "low"
}
}
func formatLegalReference(bases []V2LegalBasis) string {
if len(bases) == 0 {
return ""
}
var refs []string
for _, b := range bases {
ref := b.Article
if b.Norm != "" {
ref = b.Article + " " + b.Norm
}
refs = append(refs, ref)
}
return strings.Join(refs, ", ")
}

View File

@@ -0,0 +1,191 @@
package ucca
import (
"testing"
"time"
)
func makeTestRegulations() map[string]*V2RegulationFile {
future30 := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
future90 := time.Now().AddDate(0, 0, 90).Format("2006-01-02")
past := time.Now().AddDate(0, 0, -10).Format("2006-01-02")
return map[string]*V2RegulationFile{
"TestReg": {
Regulation: "TestReg",
Obligations: []V2Obligation{
{
ID: "TR-001", Title: "Upcoming Critical",
Deadline: &V2Deadline{Date: future30},
News: &V2ObligationNews{
Headline: "Critical Deadline", Summary: "Test summary",
ActionRequired: "Do something", Affected: "All", ActionLink: "/sdk/test",
},
LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 1"}},
},
{
ID: "TR-002", Title: "Upcoming Medium",
Description: "Medium priority regulation change.",
Deadline: &V2Deadline{Date: future90},
LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 2"}},
},
{
ID: "TR-003", Title: "Past Deadline",
Deadline: &V2Deadline{Date: past},
},
{
ID: "TR-004", Title: "No Deadline",
},
},
},
}
}
func TestGetRegulatoryNews_SortedByUrgency(t *testing.T) {
regs := makeTestRegulations()
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
if len(items) != 2 {
t.Fatalf("expected 2 items (future only), got %d", len(items))
}
// First item should be the more urgent one (30 days)
if items[0].ID != "TR-001" {
t.Errorf("expected TR-001 first (most urgent), got %s", items[0].ID)
}
if items[1].ID != "TR-002" {
t.Errorf("expected TR-002 second, got %s", items[1].ID)
}
}
func TestGetRegulatoryNews_UsesNewsField(t *testing.T) {
regs := makeTestRegulations()
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
// TR-001 has hand-crafted news
if items[0].Headline != "Critical Deadline" {
t.Errorf("expected hand-crafted headline, got %q", items[0].Headline)
}
if items[0].ActionLink != "/sdk/test" {
t.Errorf("expected /sdk/test, got %q", items[0].ActionLink)
}
}
func TestGetRegulatoryNews_AutoGeneratesWithoutNews(t *testing.T) {
regs := makeTestRegulations()
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
// TR-002 has no news field — should auto-generate
if items[1].Headline == "" {
t.Error("expected auto-generated headline")
}
if items[1].Summary == "" {
t.Error("expected auto-generated summary from description")
}
}
func TestGetRegulatoryNews_ExcludesPastDeadlines(t *testing.T) {
regs := makeTestRegulations()
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
for _, item := range items {
if item.ID == "TR-003" {
t.Error("past deadline should be excluded")
}
if item.ID == "TR-004" {
t.Error("no-deadline obligation should be excluded")
}
}
}
func TestGetRegulatoryNews_LimitWorks(t *testing.T) {
regs := makeTestRegulations()
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 1})
if len(items) != 1 {
t.Errorf("expected 1 item with limit=1, got %d", len(items))
}
}
func TestComputeUrgency(t *testing.T) {
tests := []struct {
days int
expected string
}{
{5, "critical"},
{30, "critical"},
{31, "high"},
{90, "high"},
{91, "medium"},
{180, "medium"},
{181, "low"},
{365, "low"},
}
for _, tc := range tests {
got := computeUrgency(tc.days)
if got != tc.expected {
t.Errorf("computeUrgency(%d) = %q, want %q", tc.days, got, tc.expected)
}
}
}
func TestResolveDeadline_DeadlineDate(t *testing.T) {
obl := V2Obligation{Deadline: &V2Deadline{Date: "2026-06-19"}}
d, ok := resolveDeadline(obl)
if !ok {
t.Fatal("expected deadline resolved")
}
if d.Format("2006-01-02") != "2026-06-19" {
t.Errorf("got %s", d.Format("2006-01-02"))
}
}
func TestResolveDeadline_ValidFrom(t *testing.T) {
obl := V2Obligation{ValidFrom: "2026-08-02"}
d, ok := resolveDeadline(obl)
if !ok {
t.Fatal("expected deadline resolved from valid_from")
}
if d.Format("2006-01-02") != "2026-08-02" {
t.Errorf("got %s", d.Format("2006-01-02"))
}
}
func TestResolveDeadline_NoDate(t *testing.T) {
obl := V2Obligation{}
_, ok := resolveDeadline(obl)
if ok {
t.Error("expected no deadline for empty obligation")
}
}
func TestFormatLegalReference(t *testing.T) {
bases := []V2LegalBasis{
{Norm: "DSGVO", Article: "Art. 22"},
{Norm: "BGB", Article: "§ 356a"},
}
ref := formatLegalReference(bases)
if ref != "Art. 22 DSGVO, § 356a BGB" {
t.Errorf("got %q", ref)
}
}
func TestGetRegulatoryNews_FromRealFiles(t *testing.T) {
regs, err := LoadAllV2Regulations()
if err != nil {
t.Skipf("could not load v2 regulations: %v", err)
}
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 20, HorizonDays: 730})
// Should find at least the Widerrufsbutton obligation
found := false
for _, item := range items {
if item.ID == "VBR-OBL-001" {
found = true
if item.Headline != "Widerrufsbutton-Pflicht ab 19. Juni 2026" {
t.Errorf("unexpected headline: %q", item.Headline)
}
}
}
if !found {
t.Error("expected VBR-OBL-001 in regulatory news")
}
}

View File

@@ -58,6 +58,17 @@ type V2Obligation struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
ISO27001Mapping []string `json:"iso27001_mapping,omitempty"` ISO27001Mapping []string `json:"iso27001_mapping,omitempty"`
HowToImplement string `json:"how_to_implement,omitempty"` HowToImplement string `json:"how_to_implement,omitempty"`
News *V2ObligationNews `json:"news,omitempty"`
}
// V2ObligationNews is news metadata for dashboard display.
// Own text referencing legal basis — never copied from external sources.
type V2ObligationNews struct {
Headline string `json:"headline"`
Summary string `json:"summary"`
ActionRequired string `json:"action_required"`
Affected string `json:"affected,omitempty"`
ActionLink string `json:"action_link,omitempty"`
} }
// V2LegalBasis is a legal reference in v2 format // V2LegalBasis is a legal reference in v2 format

View File

@@ -90,6 +90,13 @@
"priority": "critical", "priority": "critical",
"tom_control_ids": [], "tom_control_ids": [],
"breakpilot_feature": "/sdk/consent-management", "breakpilot_feature": "/sdk/consent-management",
"news": {
"headline": "Widerrufsbutton-Pflicht ab 19. Juni 2026",
"summary": "Ab dem 19. Juni 2026 muessen Unternehmen mit B2C-Online-Geschaeft einen digitalen Widerrufsbutton auf ihrer Website bereitstellen. Der Button muss von jeder Seite erreichbar sein und ein Zwei-Klick-Verfahren implementieren (EU-RL 2023/2673, § 356a BGB).",
"action_required": "Implementieren Sie einen gut sichtbaren 'Vertrag widerrufen'-Button auf Ihrer Website. Dokumentieren Sie das Zwei-Klick-Verfahren und stellen Sie die automatische Bestaetigungs-E-Mail sicher.",
"affected": "B2C, Online-Shop, Abo-Modelle",
"action_link": "/sdk/consent-management"
},
"valid_from": "2026-06-19", "valid_from": "2026-06-19",
"valid_until": null, "valid_until": null,
"version": "1.0" "version": "1.0"