From e7fab73a3a0c548a9e1ece510f32f6f37d508c28 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 9 Mar 2026 23:48:15 +0100 Subject: [PATCH] =?UTF-8?q?fix(company-profile):=20Projekt-aware=20Persist?= =?UTF-8?q?enz=20=E2=80=94=20Daten=20werden=20jetzt=20pro=20Projekt=20gesp?= =?UTF-8?q?eichert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/api/sdk/v1/company-profile/route.ts | 28 +- .../app/sdk/company-profile/page.tsx | 23 +- .../compliance/api/company_profile_routes.py | 385 +++++++++++------- .../042_company_profile_project_id.sql | 66 +++ 4 files changed, 345 insertions(+), 157 deletions(-) create mode 100644 backend-compliance/migrations/042_company_profile_project_id.sql diff --git a/admin-compliance/app/api/sdk/v1/company-profile/route.ts b/admin-compliance/app/api/sdk/v1/company-profile/route.ts index ab91c04..fe82267 100644 --- a/admin-compliance/app/api/sdk/v1/company-profile/route.ts +++ b/admin-compliance/app/api/sdk/v1/company-profile/route.ts @@ -2,16 +2,25 @@ import { NextRequest, NextResponse } from 'next/server' const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' +function getIds(request: NextRequest, body?: Record) { + 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 */ export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url) - const tenantId = searchParams.get('tenant_id') || 'default' + const { tenantId, qs } = getIds(request) const response = await fetch( - `${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, + `${BACKEND_URL}/api/v1/company-profile?${qs}`, { headers: { 'X-Tenant-ID': tenantId, @@ -47,10 +56,10 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { const body = await request.json() - const tenantId = body.tenant_id || 'default' + const { tenantId, qs } = getIds(request, body) const response = await fetch( - `${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, + `${BACKEND_URL}/api/v1/company-profile?${qs}`, { method: 'POST', headers: { @@ -86,11 +95,10 @@ export async function POST(request: NextRequest) { */ export async function DELETE(request: NextRequest) { try { - const { searchParams } = new URL(request.url) - const tenantId = searchParams.get('tenant_id') || 'default' + const { tenantId, qs } = getIds(request) const response = await fetch( - `${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, + `${BACKEND_URL}/api/v1/company-profile?${qs}`, { method: 'DELETE', headers: { @@ -124,10 +132,10 @@ export async function DELETE(request: NextRequest) { export async function PATCH(request: NextRequest) { try { const body = await request.json() - const tenantId = body.tenant_id || 'default' + const { tenantId, qs } = getIds(request, body) const response = await fetch( - `${BACKEND_URL}/api/v1/company-profile?tenant_id=${encodeURIComponent(tenantId)}`, + `${BACKEND_URL}/api/v1/company-profile?${qs}`, { method: 'PATCH', headers: { diff --git a/admin-compliance/app/sdk/company-profile/page.tsx b/admin-compliance/app/sdk/company-profile/page.tsx index 7e5ac6c..28b9fd7 100644 --- a/admin-compliance/app/sdk/company-profile/page.tsx +++ b/admin-compliance/app/sdk/company-profile/page.tsx @@ -2266,7 +2266,7 @@ function GenerateDocumentsButton() { // ============================================================================= export default function CompanyProfilePage() { - const { state, dispatch, setCompanyProfile, goToNextStep } = useSDK() + const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK() const [currentStep, setCurrentStep] = useState(1) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -2308,13 +2308,23 @@ export default function CompanyProfilePage() { const totalSteps = wizardSteps.length 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 useEffect(() => { let cancelled = false async function loadFromBackend() { 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) { const data = await response.json() if (data && !cancelled) { @@ -2382,7 +2392,7 @@ export default function CompanyProfilePage() { return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [projectId]) const updateFormData = (updates: Partial) => { setFormData(prev => ({ ...prev, ...updates })) @@ -2390,6 +2400,7 @@ export default function CompanyProfilePage() { // Shared payload builder for draft saves and final save (DRY) const buildProfilePayload = (isComplete: boolean) => ({ + project_id: projectId || null, company_name: formData.companyName || '', legal_form: formData.legalForm || 'GmbH', industry: formData.industry || '', @@ -2464,7 +2475,7 @@ export default function CompanyProfilePage() { const saveProfileDraft = async () => { setDraftSaveStatus('saving') try { - await fetch('/api/sdk/v1/company-profile', { + await fetch(profileApiUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildProfilePayload(false)), @@ -2511,7 +2522,7 @@ export default function CompanyProfilePage() { // Also persist to dedicated backend endpoint try { - await fetch('/api/sdk/v1/company-profile', { + await fetch(profileApiUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildProfilePayload(true)), @@ -2533,7 +2544,7 @@ export default function CompanyProfilePage() { const handleDeleteProfile = async () => { setIsDeleting(true) try { - const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default', { + const response = await fetch(profileApiUrl(), { method: 'DELETE', }) if (response.ok) { diff --git a/backend-compliance/compliance/api/company_profile_routes.py b/backend-compliance/compliance/api/company_profile_routes.py index d8c2740..9a4b758 100644 --- a/backend-compliance/compliance/api/company_profile_routes.py +++ b/backend-compliance/compliance/api/company_profile_routes.py @@ -2,7 +2,7 @@ FastAPI routes for Company Profile CRUD with audit logging. 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 - DELETE /v1/company-profile: Delete company profile - GET /v1/company-profile/audit: Get audit log for a tenant @@ -13,7 +13,7 @@ import json import logging from typing import Optional -from fastapi import APIRouter, HTTPException, Header +from fastapi import APIRouter, HTTPException, Header, Query from pydantic import BaseModel from sqlalchemy import text @@ -34,11 +34,16 @@ class CompanyProfileRequest(BaseModel): founded_year: Optional[int] = None business_model: str = "B2B" offerings: list[str] = [] + offering_urls: dict = {} company_size: str = "small" employee_count: str = "1-9" annual_revenue: str = "< 2 Mio" headquarters_country: str = "DE" + headquarters_country_other: str = "" + headquarters_street: str = "" + headquarters_zip: str = "" headquarters_city: str = "" + headquarters_state: str = "" has_international_locations: bool = False international_countries: list[str] = [] target_markets: list[str] = ["DE"] @@ -64,22 +69,30 @@ class CompanyProfileRequest(BaseModel): subject_to_iso27001: bool = False supervisory_authority: Optional[str] = None review_cycle_months: int = 12 + # Project ID (multi-project) + project_id: Optional[str] = None class CompanyProfileResponse(BaseModel): id: str tenant_id: str + project_id: Optional[str] = None company_name: str legal_form: str industry: str founded_year: Optional[int] business_model: str offerings: list[str] + offering_urls: dict = {} company_size: str employee_count: str annual_revenue: 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 international_countries: list[str] target_markets: list[str] @@ -138,15 +151,13 @@ _BASE_COLUMNS_LIST = [ "repos", "document_sources", "processing_systems", "ai_systems", "technical_contacts", "subject_to_nis2", "subject_to_ai_act", "subject_to_iso27001", "supervisory_authority", "review_cycle_months", + "project_id", "offering_urls", + "headquarters_country_other", "headquarters_street", "headquarters_zip", "headquarters_state", ] _BASE_COLUMNS = ", ".join(_BASE_COLUMNS_LIST) # 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 = { "id": (None, "STR"), "tenant_id": (None, None), @@ -156,11 +167,16 @@ _FIELD_DEFAULTS = { "founded_year": (None, None), "business_model": ("B2B", None), "offerings": ([], list), + "offering_urls": ({}, dict), "company_size": ("small", None), "employee_count": ("1-9", None), "annual_revenue": ("< 2 Mio", None), "headquarters_country": ("DE", None), + "headquarters_country_other": ("", None), + "headquarters_street": ("", None), + "headquarters_zip": ("", None), "headquarters_city": ("", None), + "headquarters_state": ("", None), "has_international_locations": (False, None), "international_countries": ([], list), "target_markets": (["DE"], list), @@ -188,6 +204,7 @@ _FIELD_DEFAULTS = { "subject_to_iso27001": (False, None), "supervisory_authority": (None, None), "review_cycle_months": (12, None), + "project_id": (None, "STR_OR_NONE"), } @@ -195,6 +212,11 @@ _FIELD_DEFAULTS = { # 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: """Convert a DB row to response model using zip-based column mapping.""" raw = dict(zip(_BASE_COLUMNS_LIST, row)) @@ -209,10 +231,8 @@ def row_to_response(row) -> CompanyProfileResponse: elif expected_type == "STR_OR_NONE": coerced[col] = str(value) if value else 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 else: - # is_data_controller needs special None-check (True when NULL) if col == "is_data_controller": coerced[col] = value if value is not None else default else: @@ -221,15 +241,16 @@ def row_to_response(row) -> CompanyProfileResponse: 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.""" try: db.execute( text("""INSERT INTO compliance_company_profile_audit - (tenant_id, action, changed_fields, changed_by) - VALUES (:tenant_id, :action, :fields::jsonb, :changed_by)"""), + (tenant_id, project_id, action, changed_fields, changed_by) + VALUES (:tenant_id, :project_id, :action, :fields::jsonb, :changed_by)"""), { "tenant_id": tenant_id, + "project_id": project_id, "action": action, "fields": json.dumps(changed_fields) if changed_fields else None, "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}") +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 # ============================================================================= @@ -246,15 +274,16 @@ def log_audit(db, tenant_id: str, action: str, changed_fields: Optional[dict], c @router.get("", response_model=CompanyProfileResponse) async def get_company_profile( tenant_id: str = "default", + project_id: Optional[str] = Query(None), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): - """Get company profile for a tenant.""" - tid = x_tenant_id or tenant_id + """Get company profile for a tenant (optionally per project).""" + tid, pid = _resolve_ids(tenant_id, x_tenant_id, project_id) db = SessionLocal() try: result = db.execute( - text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tenant_id"), - {"tenant_id": tid}, + text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"), + {"tid": tid, "pid": pid}, ) row = result.fetchone() if not row: @@ -269,135 +298,143 @@ async def get_company_profile( async def upsert_company_profile( profile: CompanyProfileRequest, tenant_id: str = "default", + project_id: Optional[str] = Query(None), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """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() try: - # Check if profile exists + # Check if profile exists for this tenant+project existing = db.execute( - text("SELECT id FROM compliance_company_profiles WHERE tenant_id = :tid"), - {"tid": tid}, + text(f"SELECT id FROM compliance_company_profiles WHERE {_where_clause()}"), + {"tid": tid, "pid": pid}, ).fetchone() 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( - text(f"""INSERT INTO compliance_company_profiles - (tenant_id, company_name, legal_form, industry, founded_year, - business_model, offerings, company_size, employee_count, annual_revenue, - headquarters_country, headquarters_city, has_international_locations, - international_countries, target_markets, primary_jurisdiction, - is_data_controller, is_data_processor, uses_ai, ai_use_cases, - dpo_name, dpo_email, legal_contact_name, legal_contact_email, - machine_builder, is_complete, - repos, document_sources, processing_systems, ai_systems, technical_contacts, - subject_to_nis2, subject_to_ai_act, subject_to_iso27001, - supervisory_authority, review_cycle_months) - VALUES (:tid, :company_name, :legal_form, :industry, :founded_year, - :business_model, :offerings::jsonb, :company_size, :employee_count, :annual_revenue, - :hq_country, :hq_city, :has_intl, :intl_countries::jsonb, - :target_markets::jsonb, :jurisdiction, - :is_controller, :is_processor, :uses_ai, :ai_use_cases::jsonb, - :dpo_name, :dpo_email, :legal_name, :legal_email, - :machine_builder::jsonb, :is_complete, - :repos::jsonb, :document_sources::jsonb, :processing_systems::jsonb, - :ai_systems::jsonb, :technical_contacts::jsonb, - :subject_to_nis2, :subject_to_ai_act, :subject_to_iso27001, - :supervisory_authority, :review_cycle_months) - ON CONFLICT (tenant_id) DO UPDATE SET - company_name = EXCLUDED.company_name, - legal_form = EXCLUDED.legal_form, - industry = EXCLUDED.industry, - founded_year = EXCLUDED.founded_year, - business_model = EXCLUDED.business_model, - offerings = EXCLUDED.offerings, - company_size = EXCLUDED.company_size, - employee_count = EXCLUDED.employee_count, - annual_revenue = EXCLUDED.annual_revenue, - headquarters_country = EXCLUDED.headquarters_country, - headquarters_city = EXCLUDED.headquarters_city, - has_international_locations = EXCLUDED.has_international_locations, - international_countries = EXCLUDED.international_countries, - target_markets = EXCLUDED.target_markets, - primary_jurisdiction = EXCLUDED.primary_jurisdiction, - is_data_controller = EXCLUDED.is_data_controller, - is_data_processor = EXCLUDED.is_data_processor, - uses_ai = EXCLUDED.uses_ai, - ai_use_cases = EXCLUDED.ai_use_cases, - dpo_name = EXCLUDED.dpo_name, - dpo_email = EXCLUDED.dpo_email, - legal_contact_name = EXCLUDED.legal_contact_name, - legal_contact_email = EXCLUDED.legal_contact_email, - machine_builder = EXCLUDED.machine_builder, - is_complete = EXCLUDED.is_complete, - repos = EXCLUDED.repos, - document_sources = EXCLUDED.document_sources, - processing_systems = EXCLUDED.processing_systems, - ai_systems = EXCLUDED.ai_systems, - technical_contacts = EXCLUDED.technical_contacts, - subject_to_nis2 = EXCLUDED.subject_to_nis2, - subject_to_ai_act = EXCLUDED.subject_to_ai_act, - subject_to_iso27001 = EXCLUDED.subject_to_iso27001, - supervisory_authority = EXCLUDED.supervisory_authority, - review_cycle_months = EXCLUDED.review_cycle_months, - updated_at = NOW() - {completed_at_clause}"""), - { - "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) + if existing: + db.execute( + text(f"""UPDATE compliance_company_profiles SET + company_name = :company_name, legal_form = :legal_form, + industry = :industry, founded_year = :founded_year, + business_model = :business_model, offerings = :offerings::jsonb, + offering_urls = :offering_urls::jsonb, + company_size = :company_size, employee_count = :employee_count, + annual_revenue = :annual_revenue, + headquarters_country = :hq_country, headquarters_country_other = :hq_country_other, + headquarters_street = :hq_street, headquarters_zip = :hq_zip, + headquarters_city = :hq_city, headquarters_state = :hq_state, + has_international_locations = :has_intl, + international_countries = :intl_countries::jsonb, + target_markets = :target_markets::jsonb, primary_jurisdiction = :jurisdiction, + is_data_controller = :is_controller, is_data_processor = :is_processor, + uses_ai = :uses_ai, ai_use_cases = :ai_use_cases::jsonb, + dpo_name = :dpo_name, dpo_email = :dpo_email, + legal_contact_name = :legal_name, legal_contact_email = :legal_email, + machine_builder = :machine_builder::jsonb, is_complete = :is_complete, + repos = :repos::jsonb, document_sources = :document_sources::jsonb, + processing_systems = :processing_systems::jsonb, + ai_systems = :ai_systems::jsonb, technical_contacts = :technical_contacts::jsonb, + subject_to_nis2 = :subject_to_nis2, subject_to_ai_act = :subject_to_ai_act, + subject_to_iso27001 = :subject_to_iso27001, + supervisory_authority = :supervisory_authority, + review_cycle_months = :review_cycle_months, + updated_at = NOW(), completed_at = {completed_at_sql} + WHERE {_where_clause()}"""), + params, + ) + else: + db.execute( + text(f"""INSERT INTO compliance_company_profiles + (tenant_id, project_id, company_name, legal_form, industry, founded_year, + business_model, offerings, offering_urls, + company_size, employee_count, annual_revenue, + headquarters_country, headquarters_country_other, + headquarters_street, headquarters_zip, headquarters_city, headquarters_state, + has_international_locations, international_countries, + target_markets, primary_jurisdiction, + is_data_controller, is_data_processor, uses_ai, ai_use_cases, + dpo_name, dpo_email, legal_contact_name, legal_contact_email, + machine_builder, is_complete, completed_at, + repos, document_sources, processing_systems, ai_systems, technical_contacts, + subject_to_nis2, subject_to_ai_act, subject_to_iso27001, + supervisory_authority, review_cycle_months) + VALUES (:tid, :pid, :company_name, :legal_form, :industry, :founded_year, + :business_model, :offerings::jsonb, :offering_urls::jsonb, + :company_size, :employee_count, :annual_revenue, + :hq_country, :hq_country_other, + :hq_street, :hq_zip, :hq_city, :hq_state, + :has_intl, :intl_countries::jsonb, + :target_markets::jsonb, :jurisdiction, + :is_controller, :is_processor, :uses_ai, :ai_use_cases::jsonb, + :dpo_name, :dpo_email, :legal_name, :legal_email, + :machine_builder::jsonb, :is_complete, {completed_at_sql}, + :repos::jsonb, :document_sources::jsonb, :processing_systems::jsonb, + :ai_systems::jsonb, :technical_contacts::jsonb, + :subject_to_nis2, :subject_to_ai_act, :subject_to_iso27001, + :supervisory_authority, :review_cycle_months)"""), + params, + ) + log_audit(db, tid, action, profile.model_dump(), None, pid) db.commit() # Fetch and return result = db.execute( - text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tid"), - {"tid": tid}, + 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 upsert company profile: {e}") @@ -409,26 +446,27 @@ async def upsert_company_profile( @router.delete("", status_code=200) async def delete_company_profile( tenant_id: str = "default", + project_id: Optional[str] = Query(None), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """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() try: existing = db.execute( - text("SELECT id FROM compliance_company_profiles WHERE tenant_id = :tid"), - {"tid": tid}, + 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") db.execute( - text("DELETE FROM compliance_company_profiles WHERE tenant_id = :tid"), - {"tid": tid}, + text(f"DELETE FROM compliance_company_profiles WHERE {_where_clause()}"), + {"tid": tid, "pid": pid}, ) - log_audit(db, tid, "delete", None, None) + log_audit(db, tid, "delete", None, None, pid) db.commit() return {"success": True, "message": "Company profile deleted"} @@ -445,22 +483,22 @@ async def delete_company_profile( @router.get("/template-context") async def get_template_context( tenant_id: str = "default", + project_id: Optional[str] = Query(None), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """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() try: result = db.execute( - text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE tenant_id = :tid"), - {"tid": tid}, + text(f"SELECT {_BASE_COLUMNS} FROM compliance_company_profiles WHERE {_where_clause()}"), + {"tid": tid, "pid": pid}, ) row = result.fetchone() if not row: raise HTTPException(status_code=404, detail="Company profile not found — fill Stammdaten first") resp = row_to_response(row) - # Build flat context dict for templates ctx = { "company_name": resp.company_name, "legal_form": resp.legal_form, @@ -483,7 +521,6 @@ async def get_template_context( "subject_to_nis2": resp.subject_to_nis2, "subject_to_ai_act": resp.subject_to_ai_act, "subject_to_iso27001": resp.subject_to_iso27001, - # Lists as-is for iteration in templates "offerings": resp.offerings, "target_markets": resp.target_markets, "international_countries": resp.international_countries, @@ -493,7 +530,6 @@ async def get_template_context( "processing_systems": resp.processing_systems, "ai_systems": resp.ai_systems, "technical_contacts": resp.technical_contacts, - # Derived helper values "has_ai_systems": len(resp.ai_systems) > 0, "processing_system_count": len(resp.processing_systems), "ai_system_count": len(resp.ai_systems), @@ -507,19 +543,20 @@ async def get_template_context( @router.get("/audit", response_model=AuditListResponse) async def get_audit_log( tenant_id: str = "default", + project_id: Optional[str] = Query(None), x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """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() try: result = db.execute( text("""SELECT id, action, changed_fields, changed_by, created_at 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 LIMIT 100"""), - {"tid": tid}, + {"tid": tid, "pid": pid}, ) rows = result.fetchall() entries = [ @@ -535,3 +572,69 @@ async def get_audit_log( return AuditListResponse(entries=entries, total=len(entries)) finally: 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() diff --git a/backend-compliance/migrations/042_company_profile_project_id.sql b/backend-compliance/migrations/042_company_profile_project_id.sql new file mode 100644 index 0000000..bdcab38 --- /dev/null +++ b/backend-compliance/migrations/042_company_profile_project_id.sql @@ -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;