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
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:
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user