fix(company-profile): Projekt-aware Persistenz — Daten werden jetzt pro Projekt gespeichert
Some checks failed
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) Failing after 36s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 21s

Problem: Company Profile nutzte hartcodiertes tenant_id=default ohne project_id.
Beim Wechsel zwischen Projekten wurden immer die gleichen (oder keine) Daten geladen.

Aenderungen:
- Migration 042: project_id Spalte + UNIQUE(tenant_id, project_id) Constraint,
  fehlende Spalten (offering_urls, Adressfelder) nachgetragen
- Backend: Alle Queries nutzen WHERE tenant_id + project_id IS NOT DISTINCT FROM
- Proxy: project_id Query-Parameter wird durchgereicht
- Frontend: projectId aus SDK-Context, profileApiUrl() Helper fuer alle API-Aufrufe
- "Weiter" speichert jetzt immer den Draft (war schon so, ging aber ins Leere)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-09 23:48:15 +01:00
parent f8917ee6fd
commit e7fab73a3a
4 changed files with 345 additions and 157 deletions

View File

@@ -2,16 +2,25 @@ import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function getIds(request: NextRequest, body?: Record<string, unknown>) {
const { searchParams } = new URL(request.url)
const tenantId = searchParams.get('tenant_id') || 'default'
const projectId = searchParams.get('project_id') || (body?.project_id as string) || ''
const qs = projectId
? `tenant_id=${encodeURIComponent(tenantId)}&project_id=${encodeURIComponent(projectId)}`
: `tenant_id=${encodeURIComponent(tenantId)}`
return { tenantId, projectId, qs }
}
/** /**
* Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile * Proxy: GET /api/sdk/v1/company-profile → Backend GET /api/v1/company-profile
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { tenantId, qs } = getIds(request)
const tenantId = searchParams.get('tenant_id') || 'default'
const response = await fetch( const response = await fetch(
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, `${BACKEND_URL}/api/v1/company-profile?${qs}`,
{ {
headers: { headers: {
'X-Tenant-ID': tenantId, 'X-Tenant-ID': tenantId,
@@ -47,10 +56,10 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const tenantId = body.tenant_id || 'default' const { tenantId, qs } = getIds(request, body)
const response = await fetch( const response = await fetch(
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, `${BACKEND_URL}/api/v1/company-profile?${qs}`,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -86,11 +95,10 @@ export async function POST(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { tenantId, qs } = getIds(request)
const tenantId = searchParams.get('tenant_id') || 'default'
const response = await fetch( const response = await fetch(
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, `${BACKEND_URL}/api/v1/company-profile?${qs}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
@@ -124,10 +132,10 @@ export async function DELETE(request: NextRequest) {
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const tenantId = body.tenant_id || 'default' const { tenantId, qs } = getIds(request, body)
const response = await fetch( const response = await fetch(
`${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, `${BACKEND_URL}/api/v1/company-profile?${qs}`,
{ {
method: 'PATCH', method: 'PATCH',
headers: { headers: {

View File

@@ -2266,7 +2266,7 @@ function GenerateDocumentsButton() {
// ============================================================================= // =============================================================================
export default function CompanyProfilePage() { export default function CompanyProfilePage() {
const { state, dispatch, setCompanyProfile, goToNextStep } = useSDK() const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK()
const [currentStep, setCurrentStep] = useState(1) const [currentStep, setCurrentStep] = useState(1)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -2308,13 +2308,23 @@ export default function CompanyProfilePage() {
const totalSteps = wizardSteps.length const totalSteps = wizardSteps.length
const lastStep = wizardSteps[wizardSteps.length - 1].id const lastStep = wizardSteps[wizardSteps.length - 1].id
// API URL helper — includes project_id if available
const profileApiUrl = (extra?: string) => {
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
const qs = params.toString()
const base = '/api/sdk/v1/company-profile' + (extra || '')
return qs ? `${base}?${qs}` : base
}
// Load existing profile: first try backend, then SDK state as fallback // Load existing profile: first try backend, then SDK state as fallback
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function loadFromBackend() { async function loadFromBackend() {
try { try {
const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default') const apiUrl = '/api/sdk/v1/company-profile' + (projectId ? `?project_id=${encodeURIComponent(projectId)}` : '')
const response = await fetch(apiUrl)
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
if (data && !cancelled) { if (data && !cancelled) {
@@ -2382,7 +2392,7 @@ export default function CompanyProfilePage() {
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [projectId])
const updateFormData = (updates: Partial<CompanyProfile>) => { const updateFormData = (updates: Partial<CompanyProfile>) => {
setFormData(prev => ({ ...prev, ...updates })) setFormData(prev => ({ ...prev, ...updates }))
@@ -2390,6 +2400,7 @@ export default function CompanyProfilePage() {
// Shared payload builder for draft saves and final save (DRY) // Shared payload builder for draft saves and final save (DRY)
const buildProfilePayload = (isComplete: boolean) => ({ const buildProfilePayload = (isComplete: boolean) => ({
project_id: projectId || null,
company_name: formData.companyName || '', company_name: formData.companyName || '',
legal_form: formData.legalForm || 'GmbH', legal_form: formData.legalForm || 'GmbH',
industry: formData.industry || '', industry: formData.industry || '',
@@ -2464,7 +2475,7 @@ export default function CompanyProfilePage() {
const saveProfileDraft = async () => { const saveProfileDraft = async () => {
setDraftSaveStatus('saving') setDraftSaveStatus('saving')
try { try {
await fetch('/api/sdk/v1/company-profile', { await fetch(profileApiUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(false)), body: JSON.stringify(buildProfilePayload(false)),
@@ -2511,7 +2522,7 @@ export default function CompanyProfilePage() {
// Also persist to dedicated backend endpoint // Also persist to dedicated backend endpoint
try { try {
await fetch('/api/sdk/v1/company-profile', { await fetch(profileApiUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(true)), body: JSON.stringify(buildProfilePayload(true)),
@@ -2533,7 +2544,7 @@ export default function CompanyProfilePage() {
const handleDeleteProfile = async () => { const handleDeleteProfile = async () => {
setIsDeleting(true) setIsDeleting(true)
try { try {
const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default', { const response = await fetch(profileApiUrl(), {
method: 'DELETE', method: 'DELETE',
}) })
if (response.ok) { if (response.ok) {

View File

@@ -2,7 +2,7 @@
FastAPI routes for Company Profile CRUD with audit logging. FastAPI routes for Company Profile CRUD with audit logging.
Endpoints: Endpoints:
- GET /v1/company-profile: Get company profile for a tenant - GET /v1/company-profile: Get company profile for a tenant (+project)
- POST /v1/company-profile: Create or update company profile - POST /v1/company-profile: Create or update company profile
- DELETE /v1/company-profile: Delete company profile - DELETE /v1/company-profile: Delete company profile
- GET /v1/company-profile/audit: Get audit log for a tenant - GET /v1/company-profile/audit: Get audit log for a tenant
@@ -13,7 +13,7 @@ import json
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Header from fastapi import APIRouter, HTTPException, Header, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
@@ -34,11 +34,16 @@ class CompanyProfileRequest(BaseModel):
founded_year: Optional[int] = None founded_year: Optional[int] = None
business_model: str = "B2B" business_model: str = "B2B"
offerings: list[str] = [] offerings: list[str] = []
offering_urls: dict = {}
company_size: str = "small" company_size: str = "small"
employee_count: str = "1-9" employee_count: str = "1-9"
annual_revenue: str = "< 2 Mio" annual_revenue: str = "< 2 Mio"
headquarters_country: str = "DE" headquarters_country: str = "DE"
headquarters_country_other: str = ""
headquarters_street: str = ""
headquarters_zip: str = ""
headquarters_city: str = "" headquarters_city: str = ""
headquarters_state: str = ""
has_international_locations: bool = False has_international_locations: bool = False
international_countries: list[str] = [] international_countries: list[str] = []
target_markets: list[str] = ["DE"] target_markets: list[str] = ["DE"]
@@ -64,22 +69,30 @@ class CompanyProfileRequest(BaseModel):
subject_to_iso27001: bool = False subject_to_iso27001: bool = False
supervisory_authority: Optional[str] = None supervisory_authority: Optional[str] = None
review_cycle_months: int = 12 review_cycle_months: int = 12
# Project ID (multi-project)
project_id: Optional[str] = None
class CompanyProfileResponse(BaseModel): class CompanyProfileResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
project_id: Optional[str] = None
company_name: str company_name: str
legal_form: str legal_form: str
industry: str industry: str
founded_year: Optional[int] founded_year: Optional[int]
business_model: str business_model: str
offerings: list[str] offerings: list[str]
offering_urls: dict = {}
company_size: str company_size: str
employee_count: str employee_count: str
annual_revenue: str annual_revenue: str
headquarters_country: str headquarters_country: str
headquarters_city: str headquarters_country_other: str = ""
headquarters_street: str = ""
headquarters_zip: str = ""
headquarters_city: str = ""
headquarters_state: str = ""
has_international_locations: bool has_international_locations: bool
international_countries: list[str] international_countries: list[str]
target_markets: list[str] target_markets: list[str]
@@ -138,15 +151,13 @@ _BASE_COLUMNS_LIST = [
"repos", "document_sources", "processing_systems", "ai_systems", "technical_contacts", "repos", "document_sources", "processing_systems", "ai_systems", "technical_contacts",
"subject_to_nis2", "subject_to_ai_act", "subject_to_iso27001", "subject_to_nis2", "subject_to_ai_act", "subject_to_iso27001",
"supervisory_authority", "review_cycle_months", "supervisory_authority", "review_cycle_months",
"project_id", "offering_urls",
"headquarters_country_other", "headquarters_street", "headquarters_zip", "headquarters_state",
] ]
_BASE_COLUMNS = ", ".join(_BASE_COLUMNS_LIST) _BASE_COLUMNS = ", ".join(_BASE_COLUMNS_LIST)
# Per-field defaults and type coercions for row_to_response. # Per-field defaults and type coercions for row_to_response.
# Each entry is (field_name, default_value, expected_type_or_None).
# - expected_type: if set, the value is checked with isinstance; if it fails,
# default_value is used instead.
# - Special sentinels: "STR" means str(value), "STR_OR_NONE" means str(v) if v else None.
_FIELD_DEFAULTS = { _FIELD_DEFAULTS = {
"id": (None, "STR"), "id": (None, "STR"),
"tenant_id": (None, None), "tenant_id": (None, None),
@@ -156,11 +167,16 @@ _FIELD_DEFAULTS = {
"founded_year": (None, None), "founded_year": (None, None),
"business_model": ("B2B", None), "business_model": ("B2B", None),
"offerings": ([], list), "offerings": ([], list),
"offering_urls": ({}, dict),
"company_size": ("small", None), "company_size": ("small", None),
"employee_count": ("1-9", None), "employee_count": ("1-9", None),
"annual_revenue": ("< 2 Mio", None), "annual_revenue": ("< 2 Mio", None),
"headquarters_country": ("DE", None), "headquarters_country": ("DE", None),
"headquarters_country_other": ("", None),
"headquarters_street": ("", None),
"headquarters_zip": ("", None),
"headquarters_city": ("", None), "headquarters_city": ("", None),
"headquarters_state": ("", None),
"has_international_locations": (False, None), "has_international_locations": (False, None),
"international_countries": ([], list), "international_countries": ([], list),
"target_markets": (["DE"], list), "target_markets": (["DE"], list),
@@ -188,6 +204,7 @@ _FIELD_DEFAULTS = {
"subject_to_iso27001": (False, None), "subject_to_iso27001": (False, None),
"supervisory_authority": (None, None), "supervisory_authority": (None, None),
"review_cycle_months": (12, None), "review_cycle_months": (12, None),
"project_id": (None, "STR_OR_NONE"),
} }
@@ -195,6 +212,11 @@ _FIELD_DEFAULTS = {
# HELPERS # HELPERS
# ============================================================================= # =============================================================================
def _where_clause():
"""WHERE clause matching tenant_id + project_id (handles NULL)."""
return "tenant_id = :tid AND project_id IS NOT DISTINCT FROM :pid"
def row_to_response(row) -> CompanyProfileResponse: def row_to_response(row) -> CompanyProfileResponse:
"""Convert a DB row to response model using zip-based column mapping.""" """Convert a DB row to response model using zip-based column mapping."""
raw = dict(zip(_BASE_COLUMNS_LIST, row)) raw = dict(zip(_BASE_COLUMNS_LIST, row))
@@ -209,10 +231,8 @@ def row_to_response(row) -> CompanyProfileResponse:
elif expected_type == "STR_OR_NONE": elif expected_type == "STR_OR_NONE":
coerced[col] = str(value) if value else None coerced[col] = str(value) if value else None
elif expected_type is not None: elif expected_type is not None:
# Type-checked field (list / dict): use value only if it matches
coerced[col] = value if isinstance(value, expected_type) else default coerced[col] = value if isinstance(value, expected_type) else default
else: else:
# is_data_controller needs special None-check (True when NULL)
if col == "is_data_controller": if col == "is_data_controller":
coerced[col] = value if value is not None else default coerced[col] = value if value is not None else default
else: else:
@@ -221,15 +241,16 @@ def row_to_response(row) -> CompanyProfileResponse:
return CompanyProfileResponse(**coerced) return CompanyProfileResponse(**coerced)
def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], changed_by: Optional[str]): def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], changed_by: Optional[str], project_id: Optional[str] = None):
"""Write an audit log entry.""" """Write an audit log entry."""
try: try:
db.execute( db.execute(
text("""INSERT INTO compliance_company_profile_audit text("""INSERT INTO compliance_company_profile_audit
(tenant_id, action, changed_fields, changed_by) (tenant_id, project_id, action, changed_fields, changed_by)
VALUES (:tenant_id, :action, :fields::jsonb, :changed_by)"""), VALUES (:tenant_id, :project_id, :action, :fields::jsonb, :changed_by)"""),
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"project_id": project_id,
"action": action, "action": action,
"fields": json.dumps(changed_fields) if changed_fields else None, "fields": json.dumps(changed_fields) if changed_fields else None,
"changed_by": changed_by, "changed_by": changed_by,
@@ -239,6 +260,13 @@ def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], c
logger.warning(f"Failed to write audit log: {e}") logger.warning(f"Failed to write audit log: {e}")
def _resolve_ids(tenant_id: str, x_tenant_id: Optional[str], project_id: Optional[str]):
"""Resolve tenant_id and project_id from params/headers."""
tid = x_tenant_id or tenant_id
pid = project_id if project_id and project_id != "null" else None
return tid, pid
# ============================================================================= # =============================================================================
# ROUTES # ROUTES
# ============================================================================= # =============================================================================
@@ -246,15 +274,16 @@ def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], c
@router.get("", response_model=CompanyProfileResponse) @router.get("", response_model=CompanyProfileResponse)
async def get_company_profile( async def get_company_profile(
tenant_id: str = "default", tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
): ):
"""Get company profile for a tenant.""" """Get company profile for a tenant (optionally per project)."""
tid = x_tenant_id or tenant_id tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
db = SessionLocal() db = SessionLocal()
try: try:
result = db.execute( result = db.execute(
text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tenant_id"), text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tenant_id": tid}, {"tid": tid, "pid": pid},
) )
row = result.fetchone() row = result.fetchone()
if not row: if not row:
@@ -269,135 +298,143 @@ async def get_company_profile(
async def upsert_company_profile( async def upsert_company_profile(
profile: CompanyProfileRequest, profile: CompanyProfileRequest,
tenant_id: str = "default", tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
): ):
"""Create or update company profile (upsert).""" """Create or update company profile (upsert)."""
tid = x_tenant_id or tenant_id tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id or profile.project_id)
db = SessionLocal() db = SessionLocal()
try: try:
# Check if profile exists # Check if profile exists for this tenant+project
existing = db.execute( existing = db.execute(
text("SELECT id FROM compliance_company_profiles WHERE tenant_id = :tid"), text(f"SELECT id FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid}, {"tid": tid, "pid": pid},
).fetchone() ).fetchone()
action = "update" if existing else "create" action = "update" if existing else "create"
completed_at_sql = "NOW()" if profile.is_complete else "NULL"
completed_at_clause = ", completed_at = NOW()" if profile.is_complete else ", completed_at = NULL" params = {
"tid": tid,
"pid": pid,
"company_name": profile.company_name,
"legal_form": profile.legal_form,
"industry": profile.industry,
"founded_year": profile.founded_year,
"business_model": profile.business_model,
"offerings": json.dumps(profile.offerings),
"offering_urls": json.dumps(profile.offering_urls),
"company_size": profile.company_size,
"employee_count": profile.employee_count,
"annual_revenue": profile.annual_revenue,
"hq_country": profile.headquarters_country,
"hq_country_other": profile.headquarters_country_other,
"hq_street": profile.headquarters_street,
"hq_zip": profile.headquarters_zip,
"hq_city": profile.headquarters_city,
"hq_state": profile.headquarters_state,
"has_intl": profile.has_international_locations,
"intl_countries": json.dumps(profile.international_countries),
"target_markets": json.dumps(profile.target_markets),
"jurisdiction": profile.primary_jurisdiction,
"is_controller": profile.is_data_controller,
"is_processor": profile.is_data_processor,
"uses_ai": profile.uses_ai,
"ai_use_cases": json.dumps(profile.ai_use_cases),
"dpo_name": profile.dpo_name,
"dpo_email": profile.dpo_email,
"legal_name": profile.legal_contact_name,
"legal_email": profile.legal_contact_email,
"machine_builder": json.dumps(profile.machine_builder) if profile.machine_builder else None,
"is_complete": profile.is_complete,
"repos": json.dumps(profile.repos),
"document_sources": json.dumps(profile.document_sources),
"processing_systems": json.dumps(profile.processing_systems),
"ai_systems": json.dumps(profile.ai_systems),
"technical_contacts": json.dumps(profile.technical_contacts),
"subject_to_nis2": profile.subject_to_nis2,
"subject_to_ai_act": profile.subject_to_ai_act,
"subject_to_iso27001": profile.subject_to_iso27001,
"supervisory_authority": profile.supervisory_authority,
"review_cycle_months": profile.review_cycle_months,
}
db.execute( if existing:
text(f"""INSERT INTO compliance_company_profiles db.execute(
(tenant_id, company_name, legal_form, industry, founded_year, text(f"""UPDATE compliance_company_profiles SET
business_model, offerings, company_size, employee_count, annual_revenue, company_name = :company_name, legal_form = :legal_form,
headquarters_country, headquarters_city, has_international_locations, industry = :industry, founded_year = :founded_year,
international_countries, target_markets, primary_jurisdiction, business_model = :business_model, offerings = :offerings::jsonb,
is_data_controller, is_data_processor, uses_ai, ai_use_cases, offering_urls = :offering_urls::jsonb,
dpo_name, dpo_email, legal_contact_name, legal_contact_email, company_size = :company_size, employee_count = :employee_count,
machine_builder, is_complete, annual_revenue = :annual_revenue,
repos, document_sources, processing_systems, ai_systems, technical_contacts, headquarters_country = :hq_country, headquarters_country_other = :hq_country_other,
subject_to_nis2, subject_to_ai_act, subject_to_iso27001, headquarters_street = :hq_street, headquarters_zip = :hq_zip,
supervisory_authority, review_cycle_months) headquarters_city = :hq_city, headquarters_state = :hq_state,
VALUES (:tid, :company_name, :legal_form, :industry, :founded_year, has_international_locations = :has_intl,
:business_model, :offerings::jsonb, :company_size, :employee_count, :annual_revenue, international_countries = :intl_countries::jsonb,
:hq_country, :hq_city, :has_intl, :intl_countries::jsonb, target_markets = :target_markets::jsonb, primary_jurisdiction = :jurisdiction,
:target_markets::jsonb, :jurisdiction, is_data_controller = :is_controller, is_data_processor = :is_processor,
:is_controller, :is_processor, :uses_ai, :ai_use_cases::jsonb, uses_ai = :uses_ai, ai_use_cases = :ai_use_cases::jsonb,
:dpo_name, :dpo_email, :legal_name, :legal_email, dpo_name = :dpo_name, dpo_email = :dpo_email,
:machine_builder::jsonb, :is_complete, legal_contact_name = :legal_name, legal_contact_email = :legal_email,
:repos::jsonb, :document_sources::jsonb, :processing_systems::jsonb, machine_builder = :machine_builder::jsonb, is_complete = :is_complete,
:ai_systems::jsonb, :technical_contacts::jsonb, repos = :repos::jsonb, document_sources = :document_sources::jsonb,
:subject_to_nis2, :subject_to_ai_act, :subject_to_iso27001, processing_systems = :processing_systems::jsonb,
:supervisory_authority, :review_cycle_months) ai_systems = :ai_systems::jsonb, technical_contacts = :technical_contacts::jsonb,
ON CONFLICT (tenant_id) DO UPDATE SET subject_to_nis2 = :subject_to_nis2, subject_to_ai_act = :subject_to_ai_act,
company_name = EXCLUDED.company_name, subject_to_iso27001 = :subject_to_iso27001,
legal_form = EXCLUDED.legal_form, supervisory_authority = :supervisory_authority,
industry = EXCLUDED.industry, review_cycle_months = :review_cycle_months,
founded_year = EXCLUDED.founded_year, updated_at = NOW(), completed_at = {completed_at_sql}
business_model = EXCLUDED.business_model, WHERE {_where_clause()}"""),
offerings = EXCLUDED.offerings, params,
company_size = EXCLUDED.company_size, )
employee_count = EXCLUDED.employee_count, else:
annual_revenue = EXCLUDED.annual_revenue, db.execute(
headquarters_country = EXCLUDED.headquarters_country, text(f"""INSERT INTO compliance_company_profiles
headquarters_city = EXCLUDED.headquarters_city, (tenant_id, project_id, company_name, legal_form, industry, founded_year,
has_international_locations = EXCLUDED.has_international_locations, business_model, offerings, offering_urls,
international_countries = EXCLUDED.international_countries, company_size, employee_count, annual_revenue,
target_markets = EXCLUDED.target_markets, headquarters_country, headquarters_country_other,
primary_jurisdiction = EXCLUDED.primary_jurisdiction, headquarters_street, headquarters_zip, headquarters_city, headquarters_state,
is_data_controller = EXCLUDED.is_data_controller, has_international_locations, international_countries,
is_data_processor = EXCLUDED.is_data_processor, target_markets, primary_jurisdiction,
uses_ai = EXCLUDED.uses_ai, is_data_controller, is_data_processor, uses_ai, ai_use_cases,
ai_use_cases = EXCLUDED.ai_use_cases, dpo_name, dpo_email, legal_contact_name, legal_contact_email,
dpo_name = EXCLUDED.dpo_name, machine_builder, is_complete, completed_at,
dpo_email = EXCLUDED.dpo_email, repos, document_sources, processing_systems, ai_systems, technical_contacts,
legal_contact_name = EXCLUDED.legal_contact_name, subject_to_nis2, subject_to_ai_act, subject_to_iso27001,
legal_contact_email = EXCLUDED.legal_contact_email, supervisory_authority, review_cycle_months)
machine_builder = EXCLUDED.machine_builder, VALUES (:tid, :pid, :company_name, :legal_form, :industry, :founded_year,
is_complete = EXCLUDED.is_complete, :business_model, :offerings::jsonb, :offering_urls::jsonb,
repos = EXCLUDED.repos, :company_size, :employee_count, :annual_revenue,
document_sources = EXCLUDED.document_sources, :hq_country, :hq_country_other,
processing_systems = EXCLUDED.processing_systems, :hq_street, :hq_zip, :hq_city, :hq_state,
ai_systems = EXCLUDED.ai_systems, :has_intl, :intl_countries::jsonb,
technical_contacts = EXCLUDED.technical_contacts, :target_markets::jsonb, :jurisdiction,
subject_to_nis2 = EXCLUDED.subject_to_nis2, :is_controller, :is_processor, :uses_ai, :ai_use_cases::jsonb,
subject_to_ai_act = EXCLUDED.subject_to_ai_act, :dpo_name, :dpo_email, :legal_name, :legal_email,
subject_to_iso27001 = EXCLUDED.subject_to_iso27001, :machine_builder::jsonb, :is_complete, {completed_at_sql},
supervisory_authority = EXCLUDED.supervisory_authority, :repos::jsonb, :document_sources::jsonb, :processing_systems::jsonb,
review_cycle_months = EXCLUDED.review_cycle_months, :ai_systems::jsonb, :technical_contacts::jsonb,
updated_at = NOW() :subject_to_nis2, :subject_to_ai_act, :subject_to_iso27001,
{completed_at_clause}"""), :supervisory_authority, :review_cycle_months)"""),
{ params,
"tid": tid, )
"company_name": profile.company_name,
"legal_form": profile.legal_form,
"industry": profile.industry,
"founded_year": profile.founded_year,
"business_model": profile.business_model,
"offerings": json.dumps(profile.offerings),
"company_size": profile.company_size,
"employee_count": profile.employee_count,
"annual_revenue": profile.annual_revenue,
"hq_country": profile.headquarters_country,
"hq_city": profile.headquarters_city,
"has_intl": profile.has_international_locations,
"intl_countries": json.dumps(profile.international_countries),
"target_markets": json.dumps(profile.target_markets),
"jurisdiction": profile.primary_jurisdiction,
"is_controller": profile.is_data_controller,
"is_processor": profile.is_data_processor,
"uses_ai": profile.uses_ai,
"ai_use_cases": json.dumps(profile.ai_use_cases),
"dpo_name": profile.dpo_name,
"dpo_email": profile.dpo_email,
"legal_name": profile.legal_contact_name,
"legal_email": profile.legal_contact_email,
"machine_builder": json.dumps(profile.machine_builder) if profile.machine_builder else None,
"is_complete": profile.is_complete,
"repos": json.dumps(profile.repos),
"document_sources": json.dumps(profile.document_sources),
"processing_systems": json.dumps(profile.processing_systems),
"ai_systems": json.dumps(profile.ai_systems),
"technical_contacts": json.dumps(profile.technical_contacts),
"subject_to_nis2": profile.subject_to_nis2,
"subject_to_ai_act": profile.subject_to_ai_act,
"subject_to_iso27001": profile.subject_to_iso27001,
"supervisory_authority": profile.supervisory_authority,
"review_cycle_months": profile.review_cycle_months,
},
)
# Audit log
log_audit(db, tid, action, profile.model_dump(), None)
log_audit(db, tid, action, profile.model_dump(), None, pid)
db.commit() db.commit()
# Fetch and return # Fetch and return
result = db.execute( result = db.execute(
text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tid"), text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid}, {"tid": tid, "pid": pid},
) )
row = result.fetchone() row = result.fetchone()
return row_to_response(row) return row_to_response(row)
except HTTPException:
raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"Failed to upsert company profile: {e}") logger.error(f"Failed to upsert company profile: {e}")
@@ -409,26 +446,27 @@ async def upsert_company_profile(
@router.delete("", status_code=200) @router.delete("", status_code=200)
async def delete_company_profile( async def delete_company_profile(
tenant_id: str = "default", tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
): ):
"""Delete company profile for a tenant (DSGVO Recht auf Loeschung, Art. 17).""" """Delete company profile for a tenant (DSGVO Recht auf Loeschung, Art. 17)."""
tid = x_tenant_id or tenant_id tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
db = SessionLocal() db = SessionLocal()
try: try:
existing = db.execute( existing = db.execute(
text("SELECT id FROM compliance_company_profiles WHERE tenant_id = :tid"), text(f"SELECT id FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid}, {"tid": tid, "pid": pid},
).fetchone() ).fetchone()
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Company profile not found") raise HTTPException(status_code=404, detail="Company profile not found")
db.execute( db.execute(
text("DELETE FROM compliance_company_profiles WHERE tenant_id = :tid"), text(f"DELETE FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid}, {"tid": tid, "pid": pid},
) )
log_audit(db, tid, "delete", None, None) log_audit(db, tid, "delete", None, None, pid)
db.commit() db.commit()
return {"success": True, "message": "Company profile deleted"} return {"success": True, "message": "Company profile deleted"}
@@ -445,22 +483,22 @@ async def delete_company_profile(
@router.get("/template-context") @router.get("/template-context")
async def get_template_context( async def get_template_context(
tenant_id: str = "default", tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
): ):
"""Return flat dict for Jinja2 template substitution in document generation.""" """Return flat dict for Jinja2 template substitution in document generation."""
tid = x_tenant_id or tenant_id tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
db = SessionLocal() db = SessionLocal()
try: try:
result = db.execute( result = db.execute(
text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tid"), text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid}, {"tid": tid, "pid": pid},
) )
row = result.fetchone() row = result.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Company profile not found — fill Stammdaten first") raise HTTPException(status_code=404, detail="Company profile not found — fill Stammdaten first")
resp = row_to_response(row) resp = row_to_response(row)
# Build flat context dict for templates
ctx = { ctx = {
"company_name": resp.company_name, "company_name": resp.company_name,
"legal_form": resp.legal_form, "legal_form": resp.legal_form,
@@ -483,7 +521,6 @@ async def get_template_context(
"subject_to_nis2": resp.subject_to_nis2, "subject_to_nis2": resp.subject_to_nis2,
"subject_to_ai_act": resp.subject_to_ai_act, "subject_to_ai_act": resp.subject_to_ai_act,
"subject_to_iso27001": resp.subject_to_iso27001, "subject_to_iso27001": resp.subject_to_iso27001,
# Lists as-is for iteration in templates
"offerings": resp.offerings, "offerings": resp.offerings,
"target_markets": resp.target_markets, "target_markets": resp.target_markets,
"international_countries": resp.international_countries, "international_countries": resp.international_countries,
@@ -493,7 +530,6 @@ async def get_template_context(
"processing_systems": resp.processing_systems, "processing_systems": resp.processing_systems,
"ai_systems": resp.ai_systems, "ai_systems": resp.ai_systems,
"technical_contacts": resp.technical_contacts, "technical_contacts": resp.technical_contacts,
# Derived helper values
"has_ai_systems": len(resp.ai_systems) > 0, "has_ai_systems": len(resp.ai_systems) > 0,
"processing_system_count": len(resp.processing_systems), "processing_system_count": len(resp.processing_systems),
"ai_system_count": len(resp.ai_systems), "ai_system_count": len(resp.ai_systems),
@@ -507,19 +543,20 @@ async def get_template_context(
@router.get("/audit", response_model=AuditListResponse) @router.get("/audit", response_model=AuditListResponse)
async def get_audit_log( async def get_audit_log(
tenant_id: str = "default", tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
): ):
"""Get audit log for company profile changes.""" """Get audit log for company profile changes."""
tid = x_tenant_id or tenant_id tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id)
db = SessionLocal() db = SessionLocal()
try: try:
result = db.execute( result = db.execute(
text("""SELECT id, action, changed_fields, changed_by, created_at text("""SELECT id, action, changed_fields, changed_by, created_at
FROM compliance_company_profile_audit FROM compliance_company_profile_audit
WHERE tenant_id = :tid WHERE tenant_id = :tid AND project_id IS NOT DISTINCT FROM :pid
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 100"""), LIMIT 100"""),
{"tid": tid}, {"tid": tid, "pid": pid},
) )
rows = result.fetchall() rows = result.fetchall()
entries = [ entries = [
@@ -535,3 +572,69 @@ async def get_audit_log(
return AuditListResponse(entries=entries, total=len(entries)) return AuditListResponse(entries=entries, total=len(entries))
finally: finally:
db.close() db.close()
@router.patch("", response_model=CompanyProfileResponse)
async def patch_company_profile(
updates: dict,
tenant_id: str = "default",
project_id: Optional[str] = Query(None),
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
):
"""Partial update for company profile."""
tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id or updates.get("project_id"))
db = SessionLocal()
try:
existing = db.execute(
text(f"SELECT id FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid, "pid": pid},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Company profile not found")
# Build SET clause from provided fields
allowed_fields = set(_BASE_COLUMNS_LIST) - {"id", "tenant_id", "project_id", "created_at", "updated_at", "completed_at"}
set_parts = []
params = {"tid": tid, "pid": pid}
jsonb_fields = {"offerings", "offering_urls", "international_countries", "target_markets",
"ai_use_cases", "machine_builder", "repos", "document_sources",
"processing_systems", "ai_systems", "technical_contacts"}
for key, value in updates.items():
if key in allowed_fields:
param_name = f"p_{key}"
if key in jsonb_fields:
set_parts.append(f"{key} = :{param_name}::jsonb")
params[param_name] = json.dumps(value) if value is not None else None
else:
set_parts.append(f"{key} = :{param_name}")
params[param_name] = value
if not set_parts:
raise HTTPException(status_code=400, detail="No valid fields to update")
set_parts.append("updated_at = NOW()")
db.execute(
text(f"UPDATE compliance_company_profiles SET {', '.join(set_parts)} WHERE {_where_clause()}"),
params,
)
log_audit(db, tid, "patch", updates, None, pid)
db.commit()
result = db.execute(
text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"),
{"tid": tid, "pid": pid},
)
row = result.fetchone()
return row_to_response(row)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to patch company profile: {e}")
raise HTTPException(status_code=500, detail="Failed to patch company profile")
finally:
db.close()

View File

@@ -0,0 +1,66 @@
-- Migration 042: Add project_id to compliance_company_profiles
-- Enables multi-project company profiles (one profile per tenant+project)
--
-- Previously: UNIQUE(tenant_id) — only one profile per tenant
-- Now: UNIQUE(tenant_id, project_id) — one profile per tenant+project
BEGIN;
-- 1. Add project_id column (nullable for backwards compat with existing rows)
ALTER TABLE compliance_company_profiles
ADD COLUMN IF NOT EXISTS project_id UUID;
-- 2. Add offering_urls column (was in frontend but missing in DB)
ALTER TABLE compliance_company_profiles
ADD COLUMN IF NOT EXISTS offering_urls JSONB DEFAULT '{}'::jsonb;
-- 3. Add address columns (were in frontend but missing in DB)
ALTER TABLE compliance_company_profiles
ADD COLUMN IF NOT EXISTS headquarters_country_other VARCHAR(255) DEFAULT '',
ADD COLUMN IF NOT EXISTS headquarters_street VARCHAR(500) DEFAULT '',
ADD COLUMN IF NOT EXISTS headquarters_zip VARCHAR(20) DEFAULT '',
ADD COLUMN IF NOT EXISTS headquarters_state VARCHAR(255) DEFAULT '';
-- 4. Drop old UNIQUE constraint on tenant_id alone
DO $$
BEGIN
-- The constraint might be named differently depending on how it was created
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'compliance_company_profiles_tenant_id_key'
) THEN
ALTER TABLE compliance_company_profiles
DROP CONSTRAINT compliance_company_profiles_tenant_id_key;
END IF;
END $$;
-- 5. Create new UNIQUE constraint on (tenant_id, project_id)
-- Using COALESCE to handle NULL project_id (legacy rows)
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_profiles_tenant_project
ON compliance_company_profiles (tenant_id, COALESCE(project_id, '00000000-0000-0000-0000-000000000000'::uuid));
-- 6. Add FK to compliance_projects (if the referenced table exists)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'compliance_projects'
) THEN
-- Only add FK if not already present
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_company_profiles_project'
) THEN
ALTER TABLE compliance_company_profiles
ADD CONSTRAINT fk_company_profiles_project
FOREIGN KEY (project_id) REFERENCES compliance_projects(id)
ON DELETE CASCADE;
END IF;
END IF;
END $$;
-- 7. Add audit log project_id column too
ALTER TABLE compliance_company_profile_audit
ADD COLUMN IF NOT EXISTS project_id UUID;
COMMIT;