package iace import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ============================================================================ // Project CRUD Operations // ============================================================================ // CreateProject creates a new IACE project func (s *Store) CreateProject(ctx context.Context, tenantID uuid.UUID, req CreateProjectRequest) (*Project, error) { project := &Project{ ID: uuid.New(), TenantID: tenantID, ParentProjectID: req.ParentProjectID, MachineName: req.MachineName, MachineType: req.MachineType, Manufacturer: req.Manufacturer, Description: req.Description, NarrativeText: req.NarrativeText, Status: ProjectStatusDraft, CEMarkingTarget: req.CEMarkingTarget, Metadata: req.Metadata, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } _, 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, 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 ) `, project.ID, project.TenantID, project.ParentProjectID, project.MachineName, project.MachineType, project.Manufacturer, project.Description, project.NarrativeText, string(project.Status), project.CEMarkingTarget, project.CompletenessScore, nil, project.TriggeredRegulations, project.Metadata, project.CreatedAt, project.UpdatedAt, project.ArchivedAt, ) if err != nil { return nil, fmt.Errorf("create project: %w", err) } return project, nil } // GetProject retrieves a project by ID func (s *Store) GetProject(ctx context.Context, id uuid.UUID) (*Project, error) { var p Project var status string var riskSummary, triggeredRegulations, metadata []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, parent_project_id, machine_name, machine_type, manufacturer, 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 `, id).Scan( &p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, &p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata, &p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get project: %w", err) } p.Status = ProjectStatus(status) json.Unmarshal(riskSummary, &p.RiskSummary) json.Unmarshal(triggeredRegulations, &p.TriggeredRegulations) json.Unmarshal(metadata, &p.Metadata) return &p, nil } // ListProjects lists all projects for a tenant func (s *Store) ListProjects(ctx context.Context, tenantID uuid.UUID) ([]Project, error) { 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, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at FROM iace_projects WHERE tenant_id = $1 ORDER BY created_at DESC `, tenantID) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } defer rows.Close() var projects []Project for rows.Next() { var p Project var status string var riskSummary, triggeredRegulations, metadata []byte err := rows.Scan( &p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, &p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata, &p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt, ) if err != nil { return nil, fmt.Errorf("list projects scan: %w", err) } p.Status = ProjectStatus(status) json.Unmarshal(riskSummary, &p.RiskSummary) json.Unmarshal(triggeredRegulations, &p.TriggeredRegulations) json.Unmarshal(metadata, &p.Metadata) projects = append(projects, p) } return projects, nil } // UpdateProject updates an existing project's mutable fields func (s *Store) UpdateProject(ctx context.Context, id uuid.UUID, req UpdateProjectRequest) (*Project, error) { // Fetch current project first project, err := s.GetProject(ctx, id) if err != nil { return nil, err } if project == nil { return nil, nil } // Apply partial updates if req.MachineName != nil { project.MachineName = *req.MachineName } if req.MachineType != nil { project.MachineType = *req.MachineType } if req.Manufacturer != nil { project.Manufacturer = *req.Manufacturer } if req.Description != nil { project.Description = *req.Description } if req.NarrativeText != nil { project.NarrativeText = *req.NarrativeText } if req.CEMarkingTarget != nil { project.CEMarkingTarget = *req.CEMarkingTarget } if req.Metadata != nil { project.Metadata = *req.Metadata } project.UpdatedAt = time.Now().UTC() _, 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 WHERE id = $1 `, id, project.MachineName, project.MachineType, project.Manufacturer, project.Description, project.NarrativeText, project.CEMarkingTarget, project.Metadata, project.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("update project: %w", err) } return project, nil } // ArchiveProject sets the archived_at timestamp and status for a project func (s *Store) ArchiveProject(ctx context.Context, id uuid.UUID) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE iace_projects SET status = $2, archived_at = $3, updated_at = $3 WHERE id = $1 `, id, string(ProjectStatusArchived), now) if err != nil { return fmt.Errorf("archive project: %w", err) } return nil } // UpdateProjectStatus updates the lifecycle status of a project func (s *Store) UpdateProjectStatus(ctx context.Context, id uuid.UUID, status ProjectStatus) error { _, err := s.pool.Exec(ctx, ` UPDATE iace_projects SET status = $2, updated_at = NOW() WHERE id = $1 `, id, string(status)) if err != nil { return fmt.Errorf("update project status: %w", err) } return nil } // UpdateProjectCompleteness updates the completeness score and risk summary func (s *Store) UpdateProjectCompleteness(ctx context.Context, id uuid.UUID, score float64, riskSummary map[string]int) error { riskSummaryJSON, err := json.Marshal(riskSummary) if err != nil { return fmt.Errorf("marshal risk summary: %w", err) } _, err = s.pool.Exec(ctx, ` UPDATE iace_projects SET completeness_score = $2, risk_summary = $3, updated_at = NOW() WHERE id = $1 `, id, score, riskSummaryJSON) if err != nil { return fmt.Errorf("update project completeness: %w", err) } return nil } // ListVariants returns all variant sub-projects for a given parent project func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project, error) { 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, completeness_score, risk_summary, triggered_regulations, metadata, created_at, updated_at, archived_at FROM iace_projects WHERE parent_project_id = $1 ORDER BY created_at DESC `, parentID) if err != nil { return nil, fmt.Errorf("list variants: %w", err) } defer rows.Close() var variants []Project for rows.Next() { var p Project var status string var riskSummary, triggeredRegulations, metadata []byte err := rows.Scan( &p.ID, &p.TenantID, &p.ParentProjectID, &p.MachineName, &p.MachineType, &p.Manufacturer, &p.Description, &p.NarrativeText, &status, &p.CEMarkingTarget, &p.CompletenessScore, &riskSummary, &triggeredRegulations, &metadata, &p.CreatedAt, &p.UpdatedAt, &p.ArchivedAt, ) if err != nil { return nil, fmt.Errorf("list variants scan: %w", err) } p.Status = ProjectStatus(status) json.Unmarshal(riskSummary, &p.RiskSummary) json.Unmarshal(triggeredRegulations, &p.TriggeredRegulations) json.Unmarshal(metadata, &p.Metadata) variants = append(variants, p) } return variants, nil } // GetVariantGap computes the gap analysis between a variant and its base project. // It counts hazards and measures in each, and returns the categories affected. func (s *Store) GetVariantGap(ctx context.Context, variantID uuid.UUID) (*VariantGap, error) { variant, err := s.GetProject(ctx, variantID) if err != nil { return nil, fmt.Errorf("get variant: %w", err) } if variant == nil { return nil, fmt.Errorf("variant project not found") } if variant.ParentProjectID == nil { return nil, fmt.Errorf("project is not a variant (no parent_project_id)") } parent, err := s.GetProject(ctx, *variant.ParentProjectID) if err != nil { return nil, fmt.Errorf("get parent: %w", err) } if parent == nil { return nil, fmt.Errorf("parent project not found") } // Count hazards and measures for both projects parentHazards, parentMeasures, err := s.countHazardsAndMeasures(ctx, parent.ID) if err != nil { return nil, fmt.Errorf("count parent stats: %w", err) } variantHazards, variantMeasures, err := s.countHazardsAndMeasures(ctx, variant.ID) if err != nil { return nil, fmt.Errorf("count variant stats: %w", err) } // Get unique hazard categories in the variant categories, err := s.getVariantHazardCategories(ctx, variant.ID) if err != nil { return nil, fmt.Errorf("get variant categories: %w", err) } return &VariantGap{ BaseProject: ProjectSummary{ ID: parent.ID, Name: parent.MachineName, HazardCount: parentHazards, MeasureCount: parentMeasures, }, Variant: ProjectSummary{ ID: variant.ID, Name: variant.MachineName, HazardCount: variantHazards, MeasureCount: variantMeasures, }, Gap: GapDetail{ AdditionalHazards: variantHazards, AdditionalMeasures: variantMeasures, CategoriesAffected: categories, }, }, nil } // countHazardsAndMeasures returns (hazardCount, measureCount) for a project func (s *Store) countHazardsAndMeasures(ctx context.Context, projectID uuid.UUID) (int, int, error) { var hazardCount int err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM iace_hazards WHERE project_id = $1`, projectID, ).Scan(&hazardCount) if err != nil { return 0, 0, err } var measureCount int err = s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM iace_mitigations m JOIN iace_hazards h ON m.hazard_id = h.id WHERE h.project_id = $1 `, projectID).Scan(&measureCount) if err != nil { return 0, 0, err } return hazardCount, measureCount, nil } // getVariantHazardCategories returns distinct hazard categories for a project func (s *Store) getVariantHazardCategories(ctx context.Context, projectID uuid.UUID) ([]string, error) { rows, err := s.pool.Query(ctx, `SELECT DISTINCT category FROM iace_hazards WHERE project_id = $1 ORDER BY category`, projectID, ) if err != nil { return nil, err } defer rows.Close() var categories []string for rows.Next() { var cat string if err := rows.Scan(&cat); err != nil { return nil, err } categories = append(categories, cat) } return categories, nil }