From a616b6427392f6c163da335d8eface16eec669e2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 18 May 2026 22:31:30 +0200 Subject: [PATCH] feat(iace): Customer-Standard-Reuse across customer's prior projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [migration-approved] Task #22. The IACE module is used by a single Maschinenhersteller, but their plants land at many different end customers. When the safety expert commissions the second or third plant at the same customer, whole classes of mitigations (company-wide PPE rules, locked-out energy isolation, customer-standard signage) are already in place there — but rediscovered from scratch every project. Migration 031: iace_projects.customer_name TEXT + partial index. The customer is stored as a plain text field rather than a normalised iace_customers table (option A from the design discussion). A proper customer-management screen can promote this to a FK later without data loss. Backend store_customer_standards.go: - ListCustomerStandardSuggestions(projectID, includeVerified) collects mitigations from all non-archived prior projects sharing the same tenant_id AND case-insensitive customer_name. Aggregates by mitigation.name (since same-named measures from different prior projects collapse into one suggestion) and surfaces: • source_project_count + source_project_names • is_customer_standard / has_verified_instances flags includeVerified=false → strictly is_customer_standard=true includeVerified=true → also status='verified' - ImportCustomerStandardSuggestion(projectID, name): for every prior (mitigation.name → hazard.name) pairing, finds matching hazards in the current project (by name) and ensures a customer-standard mitigation exists. New rows via CreateMitigation (idempotent through the UNIQUE(hazard_id, name) from migration 030); existing rows are flipped to is_relevant=true + is_customer_standard=true + status='verified' via UPDATE. Routes: GET /api/v1/iace/projects/:id/customer-standards?include_verified= POST /api/v1/iace/projects/:id/customer-standards/import body {name} Frontend: - New page /sdk/iace/[projectId]/customer-standards with: • empty-state hint pointing to Auftrag → Kundenname • per-suggestion checkbox + per-row Übernehmen button • bulk "N übernehmen" button • toggle "Auch verifizierte einbeziehen" widening the pool • per-suggestion source_project_count + status badges - Sidebar item "Kundenstandards" (building icon) placed between Verifikation and Nachweise. - Order-page now mirrors Auftraggeber.Firmenname into the top-level customer_name column on save, so the Reuse feature is fed automatically without a separate input field. The same expert effect from migration 029's is_customer_standard flag — "I already know it's covered, no evidence needed" — now becomes a cross-project asset rather than a per-project annotation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[projectId]/customer-standards/page.tsx | 211 ++++++++++++++++++ .../app/sdk/iace/[projectId]/order/page.tsx | 6 +- admin-compliance/app/sdk/iace/layout.tsx | 7 + .../iace_handler_customer_standards.go | 69 ++++++ ai-compliance-sdk/internal/app/routes.go | 5 + ai-compliance-sdk/internal/iace/models_api.go | 2 + .../internal/iace/models_entities.go | 5 + .../internal/iace/store_customer_standards.go | 211 ++++++++++++++++++ .../internal/iace/store_projects.go | 30 ++- .../migrations/031_iace_project_customer.sql | 27 +++ 10 files changed, 560 insertions(+), 13 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/customer-standards/page.tsx create mode 100644 ai-compliance-sdk/internal/api/handlers/iace_handler_customer_standards.go create mode 100644 ai-compliance-sdk/internal/iace/store_customer_standards.go create mode 100644 ai-compliance-sdk/migrations/031_iace_project_customer.sql diff --git a/admin-compliance/app/sdk/iace/[projectId]/customer-standards/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/customer-standards/page.tsx new file mode 100644 index 00000000..57468857 --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/customer-standards/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useParams } from 'next/navigation' + +type Suggestion = { + name: string + reduction_type: 'design' | 'protection' | 'information' | string + description: string + source_project_count: number + source_project_names: string[] + is_customer_standard: boolean + has_verified_instances: boolean +} + +type ProjectInfo = { customer_name?: string; machine_name?: string } + +// /sdk/iace/[projectId]/customer-standards +// +// Surfaces mitigations that the expert flagged as "Kundenstandard" (or +// successfully verified) in earlier projects of the SAME customer. Picking +// one and clicking "Übernehmen" applies it to all matching hazards in the +// current project — every match is set to is_relevant=true, +// is_customer_standard=true, status='verified'. Saves the round-trip +// through Massnahmen + Verifikation for the cases where the safety expert +// already knows the answer from a prior plant at the same site. +// +// Filter "Auch verifizierte einbeziehen" widens the pool beyond strictly +// is_customer_standard=true to also include status='verified' rows — useful +// when the customer-standard habit is not yet established in the corpus. +export default function CustomerStandardsPage() { + const params = useParams() + const projectId = params.projectId as string + + const [suggestions, setSuggestions] = useState([]) + const [project, setProject] = useState(null) + const [loading, setLoading] = useState(true) + const [includeVerified, setIncludeVerified] = useState(false) + const [importing, setImporting] = useState(null) + const [importedNames, setImportedNames] = useState>(new Set()) + const [selected, setSelected] = useState>(new Set()) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [sgRes, prRes] = await Promise.all([ + fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards?include_verified=${includeVerified}`), + fetch(`/api/sdk/v1/iace/projects/${projectId}`), + ]) + if (sgRes.ok) { + const j = await sgRes.json() + setSuggestions(j.suggestions || []) + } + if (prRes.ok) { + const j = await prRes.json() + const p = j.project || j + setProject({ customer_name: p.customer_name, machine_name: p.machine_name }) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [projectId, includeVerified]) + + useEffect(() => { load() }, [load]) + + function toggleSelect(name: string) { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(name)) next.delete(name); else next.add(name) + return next + }) + } + + async function importOne(name: string) { + setImporting(name) + try { + const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards/import`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + if (r.ok) { + setImportedNames((prev) => new Set(prev).add(name)) + setSelected((prev) => { const n = new Set(prev); n.delete(name); return n }) + } else { + const j = await r.json().catch(() => null) + setError(j?.error || `HTTP ${r.status}`) + } + } finally { + setImporting(null) + } + } + + async function importSelected() { + const names = Array.from(selected) + for (const n of names) { + await importOne(n) + } + } + + if (loading) return ( +
+
+
+ ) + + // No customer set → guide the user to set it first + const hasCustomer = !!(project?.customer_name && project.customer_name.trim() !== '') + if (!hasCustomer) { + return ( +
+

Kundenstandards

+
+ Dieses Projekt hat noch keinen Kundennamen. Damit Massnahmen aus früheren + Anlagen desselben Kunden wiederverwendet werden können, trage den Kundennamen + unter Auftrag → Kunde ein. + Sobald der Kundenname gesetzt ist, erscheint hier die Liste der wiederverwendbaren + Maßnahmen aus seinen Vorprojekten. +
+
+ ) + } + + return ( +
+
+
+

Kundenstandards

+

+ Übernimm Maßnahmen, die der Kunde {project?.customer_name} in + anderen Anlagen bereits als Standard etabliert hat. Übernehmen setzt sie für alle + passenden Gefährdungen relevant und verifiziert ohne Nachweis. +

+
+
+ + {selected.size > 0 && ( + + )} +
+
+ + {error &&
Fehler: {error}
} + + {suggestions.length === 0 && ( +
+ Keine wiederverwendbaren Maßnahmen für {project?.customer_name} gefunden. + {!includeVerified && ' Aktiviere „Auch verifizierte einbeziehen" oben rechts, um den Pool zu erweitern.'} +
+ )} + + {suggestions.length > 0 && ( +
+
+
+
Massnahme
+
Vorprojekte
+
Status
+
Aktion
+
+ {suggestions.map((s) => { + const imported = importedNames.has(s.name) + return ( +
+
+ toggleSelect(s.name)} disabled={imported} + className="accent-purple-600" /> +
+
+
{s.name}
+ {s.description &&
{s.description}
} + {s.source_project_names.length > 0 && ( +
aus: {s.source_project_names.slice(0,3).join(', ')}{s.source_project_names.length > 3 ? ` (+${s.source_project_names.length - 3})` : ''}
+ )} +
+
+ {s.source_project_count}× +
+
+ {s.is_customer_standard && Kundenstandard} + {s.has_verified_instances && !s.is_customer_standard && Verifiziert} +
+
+ {imported ? ( + ✓ Übernommen + ) : ( + + )} +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/iace/[projectId]/order/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/order/page.tsx index 6da15c9f..ff45513a 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/order/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/order/page.tsx @@ -68,10 +68,14 @@ export default function OrderPage() { setSaveState('saving') try { const merged = { ...existingMetaRef.current, order_data: next } + // Mirror Auftraggeber.Firmenname into the top-level customer_name + // column so the Customer-Standards-Reuse feature can index by it. + // Empty string → null on the backend, no broken reuse for fresh projects. + const customerName = (next.client.company || '').trim() await fetch(`/api/sdk/v1/iace/projects/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ metadata: merged }), + body: JSON.stringify({ metadata: merged, customer_name: customerName }), }) existingMetaRef.current = merged setSaveState('saved') diff --git a/admin-compliance/app/sdk/iace/layout.tsx b/admin-compliance/app/sdk/iace/layout.tsx index f1550bf0..bd302267 100644 --- a/admin-compliance/app/sdk/iace/layout.tsx +++ b/admin-compliance/app/sdk/iace/layout.tsx @@ -16,6 +16,7 @@ const IACE_NAV_ITEMS = [ { id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' }, { id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' }, { id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' }, + { id: 'customer-standards', label: 'Kundenstandards', href: '/customer-standards', icon: 'building' }, { id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' }, { id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' }, ] @@ -67,6 +68,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) { ) + case 'building': + return ( + + + + ) case 'document': return ( diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_customer_standards.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_customer_standards.go new file mode 100644 index 00000000..75cd714b --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_customer_standards.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/iace" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ListCustomerStandardSuggestions handles +// GET /api/v1/iace/projects/:id/customer-standards?include_verified=true|false +// +// Returns the set of reusable mitigations from prior projects of the same +// customer. Empty array when the project has no customer_name or no +// matching priors. The include_verified query flag controls whether +// status='verified' mitigations are included alongside the explicit +// is_customer_standard=true ones. +func (h *IACEHandler) ListCustomerStandardSuggestions(c *gin.Context) { + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + return + } + includeVerified := c.Query("include_verified") == "true" + suggestions, err := h.store.ListCustomerStandardSuggestions(c.Request.Context(), pid, includeVerified) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if suggestions == nil { + suggestions = []iace.CustomerStandardSuggestion{} + } + c.JSON(http.StatusOK, gin.H{ + "suggestions": suggestions, + "count": len(suggestions), + }) +} + +// ImportCustomerStandardSuggestion handles +// POST /api/v1/iace/projects/:id/customer-standards/import +// Body: { "name": "Sicherheitszeichen nach ISO 7010" } +// +// Applies one suggestion to all matching hazards in the current project. +// New mitigations are created idempotently; existing ones are flipped to +// is_relevant=true + is_customer_standard=true + status='verified'. +func (h *IACEHandler) ImportCustomerStandardSuggestion(c *gin.Context) { + pid, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"}) + return + } + var body struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + n, err := h.store.ImportCustomerStandardSuggestion(c.Request.Context(), pid, body.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "imported": n, + "name": body.Name, + }) +} diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go index af532563..9dee45fc 100644 --- a/ai-compliance-sdk/internal/app/routes.go +++ b/ai-compliance-sdk/internal/app/routes.go @@ -459,6 +459,11 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) { iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail) iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification) iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment) + + // Customer-Standard Reuse (migration 031): pull reusable mitigations + // across prior projects of the same customer. + iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions) + iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion) } } diff --git a/ai-compliance-sdk/internal/iace/models_api.go b/ai-compliance-sdk/internal/iace/models_api.go index 13d9d919..04808795 100644 --- a/ai-compliance-sdk/internal/iace/models_api.go +++ b/ai-compliance-sdk/internal/iace/models_api.go @@ -16,6 +16,7 @@ type CreateProjectRequest struct { MachineName string `json:"machine_name" binding:"required"` MachineType string `json:"machine_type" binding:"required"` Manufacturer string `json:"manufacturer" binding:"required"` + CustomerName string `json:"customer_name,omitempty"` Description string `json:"description,omitempty"` NarrativeText string `json:"narrative_text,omitempty"` CEMarkingTarget string `json:"ce_marking_target,omitempty"` @@ -27,6 +28,7 @@ type UpdateProjectRequest struct { MachineName *string `json:"machine_name,omitempty"` MachineType *string `json:"machine_type,omitempty"` Manufacturer *string `json:"manufacturer,omitempty"` + CustomerName *string `json:"customer_name,omitempty"` Description *string `json:"description,omitempty"` NarrativeText *string `json:"narrative_text,omitempty"` CEMarkingTarget *string `json:"ce_marking_target,omitempty"` diff --git a/ai-compliance-sdk/internal/iace/models_entities.go b/ai-compliance-sdk/internal/iace/models_entities.go index 60fa68ae..bc1cdc96 100644 --- a/ai-compliance-sdk/internal/iace/models_entities.go +++ b/ai-compliance-sdk/internal/iace/models_entities.go @@ -19,6 +19,11 @@ type Project struct { MachineName string `json:"machine_name"` MachineType string `json:"machine_type"` Manufacturer string `json:"manufacturer"` + // CustomerName is the end customer (Anlagenbetreiber). Optional — + // projects without a customer are still valid, but customer-standard + // reuse only fires across projects sharing the same non-empty value + // (case-insensitive match, see customerKey()). + CustomerName string `json:"customer_name,omitempty"` Description string `json:"description,omitempty"` NarrativeText string `json:"narrative_text,omitempty"` Status ProjectStatus `json:"status"` diff --git a/ai-compliance-sdk/internal/iace/store_customer_standards.go b/ai-compliance-sdk/internal/iace/store_customer_standards.go new file mode 100644 index 00000000..931deea0 --- /dev/null +++ b/ai-compliance-sdk/internal/iace/store_customer_standards.go @@ -0,0 +1,211 @@ +package iace + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// CustomerStandardSuggestion aggregates one reusable mitigation across prior +// projects of the same customer. The same mitigation name may appear in +// multiple prior projects; we collapse them into a single suggestion and +// count the prior occurrences so the expert sees a confidence signal. +type CustomerStandardSuggestion struct { + Name string `json:"name"` + ReductionType string `json:"reduction_type"` + Description string `json:"description"` + // SourceProjectCount tells the expert in how many of the customer's + // earlier projects this mitigation was already flagged. Higher count + // = stronger reuse signal. + SourceProjectCount int `json:"source_project_count"` + SourceProjectNames []string `json:"source_project_names"` + IsCustomerStandard bool `json:"is_customer_standard"` + HasVerifiedInstances bool `json:"has_verified_instances"` +} + +// ListCustomerStandardSuggestions returns reusable mitigations from prior +// projects of the same customer as projectID. The customer key is the +// case-insensitive trimmed customer_name; an empty customer_name short- +// circuits to an empty result. +// +// includeVerified=false → only mitigations with is_customer_standard=true +// includeVerified=true → also include status='verified' mitigations +// (broader pool, useful when the customer-standard +// habit isn't yet established in the data) +func (s *Store) ListCustomerStandardSuggestions( + ctx context.Context, + projectID uuid.UUID, + includeVerified bool, +) ([]CustomerStandardSuggestion, error) { + // Resolve the customer + tenant for the current project. + var tenantID uuid.UUID + var customerName string + err := s.pool.QueryRow(ctx, + `SELECT tenant_id, COALESCE(customer_name, '') FROM iace_projects WHERE id = $1`, + projectID, + ).Scan(&tenantID, &customerName) + if err != nil { + return nil, fmt.Errorf("resolve project for customer-standards: %w", err) + } + customerName = strings.TrimSpace(customerName) + if customerName == "" { + return []CustomerStandardSuggestion{}, nil + } + + filterClause := "m.is_customer_standard = TRUE" + if includeVerified { + filterClause = "(m.is_customer_standard = TRUE OR m.status = 'verified')" + } + + query := fmt.Sprintf(` + SELECT + m.name, + m.reduction_type, + MAX(m.description) AS description, + COUNT(DISTINCT p.id) AS source_count, + array_agg(DISTINCT p.machine_name ORDER BY p.machine_name) AS source_names, + BOOL_OR(m.is_customer_standard) AS has_customer_std, + BOOL_OR(m.status = 'verified') AS has_verified + FROM iace_mitigations m + JOIN iace_hazards h ON h.id = m.hazard_id + JOIN iace_projects p ON p.id = h.project_id + WHERE p.tenant_id = $1 + AND p.id <> $2 + AND p.archived_at IS NULL + AND LOWER(TRIM(COALESCE(p.customer_name, ''))) = LOWER($3) + AND %s + GROUP BY m.name, m.reduction_type + ORDER BY source_count DESC, m.name + `, filterClause) + + rows, err := s.pool.Query(ctx, query, tenantID, projectID, customerName) + if err != nil { + return nil, fmt.Errorf("query customer-standards: %w", err) + } + defer rows.Close() + + var out []CustomerStandardSuggestion + for rows.Next() { + var sg CustomerStandardSuggestion + if scanErr := rows.Scan( + &sg.Name, &sg.ReductionType, &sg.Description, + &sg.SourceProjectCount, &sg.SourceProjectNames, + &sg.IsCustomerStandard, &sg.HasVerifiedInstances, + ); scanErr != nil { + return nil, fmt.Errorf("scan customer-standards: %w", scanErr) + } + out = append(out, sg) + } + return out, nil +} + +// ImportCustomerStandardSuggestion applies a suggestion to the current +// project: for every hazard in the project whose name matches one of the +// suggestion's source hazards (by mitigation.name → hazard.name pairing in +// prior projects), it ensures a relevant + customer-standard mitigation +// exists. New mitigations are inserted via CreateMitigation (idempotent +// via UNIQUE(hazard_id, name)), existing ones are flipped to +// is_relevant=true + is_customer_standard=true + status='verified'. +// +// Returns the number of mitigations affected (created + updated). +func (s *Store) ImportCustomerStandardSuggestion( + ctx context.Context, + projectID uuid.UUID, + mitigationName string, +) (int, error) { + // Find tenant + customer of the target project. + var tenantID uuid.UUID + var customerName string + if err := s.pool.QueryRow(ctx, + `SELECT tenant_id, COALESCE(customer_name, '') FROM iace_projects WHERE id = $1`, + projectID, + ).Scan(&tenantID, &customerName); err != nil { + return 0, fmt.Errorf("resolve project: %w", err) + } + customerName = strings.TrimSpace(customerName) + if customerName == "" { + return 0, fmt.Errorf("project has no customer_name — nothing to reuse") + } + + // Collect the hazard names this mitigation was attached to in the + // customer's prior projects + a representative reduction_type/description. + priorRows, err := s.pool.Query(ctx, ` + SELECT DISTINCT h.name, m.reduction_type, COALESCE(m.description, '') + FROM iace_mitigations m + JOIN iace_hazards h ON h.id = m.hazard_id + JOIN iace_projects p ON p.id = h.project_id + WHERE p.tenant_id = $1 + AND p.id <> $2 + AND p.archived_at IS NULL + AND LOWER(TRIM(COALESCE(p.customer_name, ''))) = LOWER($3) + AND m.name = $4 + `, tenantID, projectID, customerName, mitigationName) + if err != nil { + return 0, fmt.Errorf("collect prior hazards: %w", err) + } + defer priorRows.Close() + + type proto struct{ hazardName, reductionType, description string } + var prototypes []proto + for priorRows.Next() { + var p proto + if err := priorRows.Scan(&p.hazardName, &p.reductionType, &p.description); err != nil { + return 0, err + } + prototypes = append(prototypes, p) + } + if len(prototypes) == 0 { + return 0, nil + } + + // For every prototype hazard name, find the matching hazard in the + // current project (same name) and ensure a relevant + customer-standard + // mitigation with mitigationName exists for it. + affected := 0 + for _, p := range prototypes { + var hazardIDs []uuid.UUID + hazRows, err := s.pool.Query(ctx, + `SELECT id FROM iace_hazards WHERE project_id = $1 AND name = $2`, + projectID, p.hazardName, + ) + if err != nil { + return affected, fmt.Errorf("find target hazards: %w", err) + } + for hazRows.Next() { + var hid uuid.UUID + if scanErr := hazRows.Scan(&hid); scanErr != nil { + hazRows.Close() + return affected, scanErr + } + hazardIDs = append(hazardIDs, hid) + } + hazRows.Close() + + for _, hid := range hazardIDs { + // Idempotent insert; UPDATE sets relevance + verified state. + _, err := s.CreateMitigation(ctx, CreateMitigationRequest{ + HazardID: hid, + Name: mitigationName, + Description: p.description, + ReductionType: ReductionType(p.reductionType), + }) + if err != nil { + return affected, fmt.Errorf("create mitigation: %w", err) + } + if _, err := s.pool.Exec(ctx, ` + UPDATE iace_mitigations + SET is_relevant = TRUE, + is_customer_standard = TRUE, + status = 'verified', + updated_at = NOW() + WHERE hazard_id = $1 AND name = $2 + `, hid, mitigationName); err != nil { + return affected, fmt.Errorf("upgrade mitigation: %w", err) + } + affected++ + } + } + return affected, nil +} diff --git a/ai-compliance-sdk/internal/iace/store_projects.go b/ai-compliance-sdk/internal/iace/store_projects.go index 047c5fdc..792c88f6 100644 --- a/ai-compliance-sdk/internal/iace/store_projects.go +++ b/ai-compliance-sdk/internal/iace/store_projects.go @@ -23,6 +23,7 @@ func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req Creat MachineName: req.MachineName, MachineType: req.MachineType, Manufacturer: req.Manufacturer, + CustomerName: req.CustomerName, Description: req.Description, NarrativeText: req.NarrativeText, Status: ProjectStatusDraft, @@ -35,19 +36,19 @@ func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req Creat _, err := s.pool.Exec(ctx, ` INSERT INTO iace_projects ( id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer, - description, narrative_text, status, ce_marking_target, + customer_name, description, narrative_text, status, ce_marking_target, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, - $11, $12, $13, $14, - $15, $16, $17 + $7, $8, $9, $10, $11, + $12, $13, $14, $15, + $16, $17, $18 ) `, project.ID, project.TenantID, project.ParentProjectID, project.MachineName, project.MachineType, project.Manufacturer, - project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget, + project.CustomerName, project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget, project.CompletenessScore, nil, project.TriggeredRegulations, project.Metadata, project.CreatedAt, project.UpdatedAt, project.ArchivedAt, ) @@ -67,7 +68,7 @@ func (s *Store) GetProject(ctx context.Context, id uuid.UUID) (*Project, error) err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer, - description, narrative_text, status, ce_marking_target, + COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at FROM iace_projects WHERE id = $1 @@ -97,7 +98,7 @@ func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project rows, err := s.pool.Query(ctx, ` SELECT id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer, - description, narrative_text, status, ce_marking_target, + COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at FROM iace_projects WHERE tenant_id = $1 @@ -116,7 +117,7 @@ func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project err := rows.Scan( &p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer, - &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, + &p.CustomerName, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, &p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata, &p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt, ) @@ -156,6 +157,9 @@ func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProje if req.Manufacturer != nil { project.Manufacturer = *req.Manufacturer } + if req.CustomerName != nil { + project.CustomerName = *req.CustomerName + } if req.Description != nil { project.Description = *req.Description } @@ -174,11 +178,13 @@ func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProje _, err = s.pool.Exec(ctx, ` UPDATE iace_projects SET machine_name = $2, machine_type = $3, manufacturer = $4, - description = $5, narrative_text = $6, ce_marking_target = $7, - metadata = $8, updated_at = $9 + customer_name = $5, + description = $6, narrative_text = $7, ce_marking_target = $8, + metadata = $9, updated_at = $10 WHERE id = $1 `, id, project.MachineName, project.MachineType, project.Manufacturer, + project.CustomerName, project.Description, project.NarrativeText, project.CEMarkingTarget, project.Metadata, project.UpdatedAt, ) @@ -250,7 +256,7 @@ func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project rows, err := s.pool.Query(ctx, ` SELECT id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer, - description, narrative_text, status, ce_marking_target, + COALESCE(customer_name, ''), description, narrative_text, status, ce_marking_target, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at FROM iace_projects WHERE parent_project_id = $1 @@ -269,7 +275,7 @@ func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project err := rows.Scan( &p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer, - &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, + &p.CustomerName, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, &p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata, &p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt, ) diff --git a/ai-compliance-sdk/migrations/031_iace_project_customer.sql b/ai-compliance-sdk/migrations/031_iace_project_customer.sql new file mode 100644 index 00000000..b6ce10a2 --- /dev/null +++ b/ai-compliance-sdk/migrations/031_iace_project_customer.sql @@ -0,0 +1,27 @@ +-- Migration 031: customer_name on iace_projects + reuse-helper index +-- ========================================================================== +-- The IACE module is operated by a single Maschinenhersteller (the SDK +-- user), but their plants land at many different end customers. A safety +-- expert who commissions the second or third plant at the same customer +-- often finds that whole classes of mitigations are already in place +-- there (company-wide PPE rules, locked-out energy isolation, customer- +-- standard signage, etc.). Today, this expert knowledge is rediscovered +-- per project. +-- +-- This migration introduces a plain customer_name field on the project +-- (no separate customer table yet — Option A from the design discussion; +-- normalised iace_customers can come later when a real customer-management +-- screen is built). The field is optional so existing projects without a +-- customer remain valid. +-- +-- The partial index makes the customer-standards lookup cheap: only +-- projects with a non-empty customer_name participate, since reuse is +-- meaningless without it. +-- ========================================================================== + +ALTER TABLE iace_projects + ADD COLUMN IF NOT EXISTS customer_name TEXT; + +CREATE INDEX IF NOT EXISTS idx_iace_projects_customer_name + ON iace_projects(customer_name) + WHERE customer_name IS NOT NULL AND customer_name <> '';