feat(iace): Customer-Standard-Reuse across customer's prior projects
CI / detect-changes (push) Successful in 10s
CI / guardrail-integrity (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-go (push) Successful in 47s
CI / nodejs-build (push) Successful in 2m46s
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

[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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-18 22:31:30 +02:00
parent 27384aea09
commit a616b64273
10 changed files with 560 additions and 13 deletions
@@ -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,
})
}
+5
View File
@@ -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)
}
}
@@ -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"`
@@ -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"`
@@ -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
}
@@ -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,
)
@@ -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 <> '';