package vendor import ( "context" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles vendor compliance data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new vendor compliance store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Vendor CRUD Operations // ============================================================================ // CreateVendor creates a new vendor func (s *Store) CreateVendor(ctx context.Context, v *Vendor) error { v.ID = uuid.New() v.CreatedAt = time.Now().UTC() v.UpdatedAt = v.CreatedAt if v.Status == "" { v.Status = VendorStatusPendingReview } _, err := s.pool.Exec(ctx, ` INSERT INTO vendor_vendors ( id, tenant_id, name, legal_form, country, address, website, contact_name, contact_email, contact_phone, contact_department, role, service_category, service_description, data_access_level, processing_locations, certifications, inherent_risk_score, residual_risk_score, manual_risk_adjustment, review_frequency, last_review_date, next_review_date, processing_activity_ids, status, template_id, created_at, updated_at, created_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29 ) `, v.ID, v.TenantID, v.Name, v.LegalForm, v.Country, v.Address, v.Website, v.ContactName, v.ContactEmail, v.ContactPhone, v.ContactDepartment, string(v.Role), v.ServiceCategory, v.ServiceDescription, v.DataAccessLevel, v.ProcessingLocations, v.Certifications, v.InherentRiskScore, v.ResidualRiskScore, v.ManualRiskAdjustment, v.ReviewFrequency, v.LastReviewDate, v.NextReviewDate, v.ProcessingActivityIDs, string(v.Status), v.TemplateID, v.CreatedAt, v.UpdatedAt, v.CreatedBy, ) return err } // GetVendor retrieves a vendor by ID and tenant func (s *Store) GetVendor(ctx context.Context, tenantID, id string) (*Vendor, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } vid, err := uuid.Parse(id) if err != nil { return nil, fmt.Errorf("invalid vendor id: %w", err) } var v Vendor var role, status string err = s.pool.QueryRow(ctx, ` SELECT id, tenant_id, name, legal_form, country, address, website, contact_name, contact_email, contact_phone, contact_department, role, service_category, service_description, data_access_level, processing_locations, certifications, inherent_risk_score, residual_risk_score, manual_risk_adjustment, review_frequency, last_review_date, next_review_date, processing_activity_ids, status, template_id, created_at, updated_at, created_by FROM vendor_vendors WHERE id = $1 AND tenant_id = $2 `, vid, tid).Scan( &v.ID, &v.TenantID, &v.Name, &v.LegalForm, &v.Country, &v.Address, &v.Website, &v.ContactName, &v.ContactEmail, &v.ContactPhone, &v.ContactDepartment, &role, &v.ServiceCategory, &v.ServiceDescription, &v.DataAccessLevel, &v.ProcessingLocations, &v.Certifications, &v.InherentRiskScore, &v.ResidualRiskScore, &v.ManualRiskAdjustment, &v.ReviewFrequency, &v.LastReviewDate, &v.NextReviewDate, &v.ProcessingActivityIDs, &status, &v.TemplateID, &v.CreatedAt, &v.UpdatedAt, &v.CreatedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } v.Role = VendorRole(role) v.Status = VendorStatus(status) return &v, nil } // ListVendors lists all vendors for a tenant ordered by name func (s *Store) ListVendors(ctx context.Context, tenantID string) ([]*Vendor, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } rows, err := s.pool.Query(ctx, ` SELECT id, tenant_id, name, legal_form, country, address, website, contact_name, contact_email, contact_phone, contact_department, role, service_category, service_description, data_access_level, processing_locations, certifications, inherent_risk_score, residual_risk_score, manual_risk_adjustment, review_frequency, last_review_date, next_review_date, processing_activity_ids, status, template_id, created_at, updated_at, created_by FROM vendor_vendors WHERE tenant_id = $1 ORDER BY name ASC `, tid) if err != nil { return nil, err } defer rows.Close() var vendors []*Vendor for rows.Next() { v, err := scanVendor(rows) if err != nil { return nil, err } vendors = append(vendors, v) } return vendors, nil } // UpdateVendor updates an existing vendor func (s *Store) UpdateVendor(ctx context.Context, v *Vendor) error { v.UpdatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE vendor_vendors SET name = $3, legal_form = $4, country = $5, address = $6, website = $7, contact_name = $8, contact_email = $9, contact_phone = $10, contact_department = $11, role = $12, service_category = $13, service_description = $14, data_access_level = $15, processing_locations = $16, certifications = $17, inherent_risk_score = $18, residual_risk_score = $19, manual_risk_adjustment = $20, review_frequency = $21, last_review_date = $22, next_review_date = $23, processing_activity_ids = $24, status = $25, template_id = $26, updated_at = $27 WHERE id = $1 AND tenant_id = $2 `, v.ID, v.TenantID, v.Name, v.LegalForm, v.Country, v.Address, v.Website, v.ContactName, v.ContactEmail, v.ContactPhone, v.ContactDepartment, string(v.Role), v.ServiceCategory, v.ServiceDescription, v.DataAccessLevel, v.ProcessingLocations, v.Certifications, v.InherentRiskScore, v.ResidualRiskScore, v.ManualRiskAdjustment, v.ReviewFrequency, v.LastReviewDate, v.NextReviewDate, v.ProcessingActivityIDs, string(v.Status), v.TemplateID, v.UpdatedAt, ) return err } // DeleteVendor deletes a vendor by ID and tenant func (s *Store) DeleteVendor(ctx context.Context, tenantID, id string) error { tid, err := uuid.Parse(tenantID) if err != nil { return fmt.Errorf("invalid tenant_id: %w", err) } vid, err := uuid.Parse(id) if err != nil { return fmt.Errorf("invalid vendor id: %w", err) } _, err = s.pool.Exec(ctx, "DELETE FROM vendor_vendors WHERE id = $1 AND tenant_id = $2", vid, tid, ) return err } // ============================================================================ // Contract CRUD Operations // ============================================================================ // CreateContract creates a new contract document func (s *Store) CreateContract(ctx context.Context, c *Contract) error { c.ID = uuid.New() c.CreatedAt = time.Now().UTC() c.UpdatedAt = c.CreatedAt if c.ReviewStatus == "" { c.ReviewStatus = "PENDING" } _, err := s.pool.Exec(ctx, ` INSERT INTO vendor_contracts ( id, tenant_id, vendor_id, file_name, original_name, mime_type, file_size, storage_path, document_type, parties, effective_date, expiration_date, auto_renewal, renewal_notice_period, review_status, review_completed_at, compliance_score, version, previous_version_id, extracted_text, page_count, created_at, updated_at, created_by ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24 ) `, c.ID, c.TenantID, c.VendorID, c.FileName, c.OriginalName, c.MimeType, c.FileSize, c.StoragePath, string(c.DocumentType), c.Parties, c.EffectiveDate, c.ExpirationDate, c.AutoRenewal, c.RenewalNoticePeriod, c.ReviewStatus, c.ReviewCompletedAt, c.ComplianceScore, c.Version, c.PreviousVersionID, c.ExtractedText, c.PageCount, c.CreatedAt, c.UpdatedAt, c.CreatedBy, ) return err } // GetContract retrieves a contract by ID and tenant func (s *Store) GetContract(ctx context.Context, tenantID, id string) (*Contract, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } cid, err := uuid.Parse(id) if err != nil { return nil, fmt.Errorf("invalid contract id: %w", err) } var c Contract var documentType string err = s.pool.QueryRow(ctx, ` SELECT id, tenant_id, vendor_id, file_name, original_name, mime_type, file_size, storage_path, document_type, parties, effective_date, expiration_date, auto_renewal, renewal_notice_period, review_status, review_completed_at, compliance_score, version, previous_version_id, extracted_text, page_count, created_at, updated_at, created_by FROM vendor_contracts WHERE id = $1 AND tenant_id = $2 `, cid, tid).Scan( &c.ID, &c.TenantID, &c.VendorID, &c.FileName, &c.OriginalName, &c.MimeType, &c.FileSize, &c.StoragePath, &documentType, &c.Parties, &c.EffectiveDate, &c.ExpirationDate, &c.AutoRenewal, &c.RenewalNoticePeriod, &c.ReviewStatus, &c.ReviewCompletedAt, &c.ComplianceScore, &c.Version, &c.PreviousVersionID, &c.ExtractedText, &c.PageCount, &c.CreatedAt, &c.UpdatedAt, &c.CreatedBy, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } c.DocumentType = DocumentType(documentType) return &c, nil } // ListContracts lists contracts for a tenant, optionally filtered by vendor func (s *Store) ListContracts(ctx context.Context, tenantID string, vendorID *string) ([]*Contract, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } query := ` SELECT id, tenant_id, vendor_id, file_name, original_name, mime_type, file_size, storage_path, document_type, parties, effective_date, expiration_date, auto_renewal, renewal_notice_period, review_status, review_completed_at, compliance_score, version, previous_version_id, extracted_text, page_count, created_at, updated_at, created_by FROM vendor_contracts WHERE tenant_id = $1` args := []interface{}{tid} if vendorID != nil { vid, err := uuid.Parse(*vendorID) if err != nil { return nil, fmt.Errorf("invalid vendor_id: %w", err) } query += " AND vendor_id = $2" args = append(args, vid) } query += " ORDER BY created_at DESC" rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var contracts []*Contract for rows.Next() { c, err := scanContract(rows) if err != nil { return nil, err } contracts = append(contracts, c) } return contracts, nil } // UpdateContract updates an existing contract func (s *Store) UpdateContract(ctx context.Context, c *Contract) error { c.UpdatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE vendor_contracts SET vendor_id = $3, file_name = $4, original_name = $5, mime_type = $6, file_size = $7, storage_path = $8, document_type = $9, parties = $10, effective_date = $11, expiration_date = $12, auto_renewal = $13, renewal_notice_period = $14, review_status = $15, review_completed_at = $16, compliance_score = $17, version = $18, previous_version_id = $19, extracted_text = $20, page_count = $21, updated_at = $22 WHERE id = $1 AND tenant_id = $2 `, c.ID, c.TenantID, c.VendorID, c.FileName, c.OriginalName, c.MimeType, c.FileSize, c.StoragePath, string(c.DocumentType), c.Parties, c.EffectiveDate, c.ExpirationDate, c.AutoRenewal, c.RenewalNoticePeriod, c.ReviewStatus, c.ReviewCompletedAt, c.ComplianceScore, c.Version, c.PreviousVersionID, c.ExtractedText, c.PageCount, c.UpdatedAt, ) return err } // DeleteContract deletes a contract by ID and tenant func (s *Store) DeleteContract(ctx context.Context, tenantID, id string) error { tid, err := uuid.Parse(tenantID) if err != nil { return fmt.Errorf("invalid tenant_id: %w", err) } cid, err := uuid.Parse(id) if err != nil { return fmt.Errorf("invalid contract id: %w", err) } _, err = s.pool.Exec(ctx, "DELETE FROM vendor_contracts WHERE id = $1 AND tenant_id = $2", cid, tid, ) return err } // ============================================================================ // Finding CRUD Operations // ============================================================================ // CreateFinding creates a new finding func (s *Store) CreateFinding(ctx context.Context, f *Finding) error { f.ID = uuid.New() f.CreatedAt = time.Now().UTC() f.UpdatedAt = f.CreatedAt if f.Status == "" { f.Status = FindingStatusOpen } _, err := s.pool.Exec(ctx, ` INSERT INTO vendor_findings ( id, tenant_id, contract_id, vendor_id, finding_type, category, severity, title, description, recommendation, citations, status, assignee, due_date, resolution, resolved_at, resolved_by, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) `, f.ID, f.TenantID, f.ContractID, f.VendorID, string(f.FindingType), f.Category, f.Severity, f.Title, f.Description, f.Recommendation, f.Citations, string(f.Status), f.Assignee, f.DueDate, f.Resolution, f.ResolvedAt, f.ResolvedBy, f.CreatedAt, f.UpdatedAt, ) return err } // GetFinding retrieves a finding by ID and tenant func (s *Store) GetFinding(ctx context.Context, tenantID, id string) (*Finding, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } fid, err := uuid.Parse(id) if err != nil { return nil, fmt.Errorf("invalid finding id: %w", err) } var f Finding var findingType, status string err = s.pool.QueryRow(ctx, ` SELECT id, tenant_id, contract_id, vendor_id, finding_type, category, severity, title, description, recommendation, citations, status, assignee, due_date, resolution, resolved_at, resolved_by, created_at, updated_at FROM vendor_findings WHERE id = $1 AND tenant_id = $2 `, fid, tid).Scan( &f.ID, &f.TenantID, &f.ContractID, &f.VendorID, &findingType, &f.Category, &f.Severity, &f.Title, &f.Description, &f.Recommendation, &f.Citations, &status, &f.Assignee, &f.DueDate, &f.Resolution, &f.ResolvedAt, &f.ResolvedBy, &f.CreatedAt, &f.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } f.FindingType = FindingType(findingType) f.Status = FindingStatus(status) return &f, nil } // ListFindings lists findings for a tenant with optional vendor and contract filters func (s *Store) ListFindings(ctx context.Context, tenantID string, vendorID *string, contractID *string) ([]*Finding, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } query := ` SELECT id, tenant_id, contract_id, vendor_id, finding_type, category, severity, title, description, recommendation, citations, status, assignee, due_date, resolution, resolved_at, resolved_by, created_at, updated_at FROM vendor_findings WHERE tenant_id = $1` args := []interface{}{tid} argIdx := 2 if vendorID != nil { vid, err := uuid.Parse(*vendorID) if err != nil { return nil, fmt.Errorf("invalid vendor_id: %w", err) } query += fmt.Sprintf(" AND vendor_id = $%d", argIdx) args = append(args, vid) argIdx++ } if contractID != nil { query += fmt.Sprintf(" AND contract_id = $%d", argIdx) args = append(args, *contractID) argIdx++ } query += " ORDER BY created_at DESC" rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var findings []*Finding for rows.Next() { f, err := scanFinding(rows) if err != nil { return nil, err } findings = append(findings, f) } return findings, nil } // UpdateFinding updates an existing finding func (s *Store) UpdateFinding(ctx context.Context, f *Finding) error { f.UpdatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE vendor_findings SET finding_type = $3, category = $4, severity = $5, title = $6, description = $7, recommendation = $8, citations = $9, status = $10, assignee = $11, due_date = $12, resolution = $13, resolved_at = $14, resolved_by = $15, updated_at = $16 WHERE id = $1 AND tenant_id = $2 `, f.ID, f.TenantID, string(f.FindingType), f.Category, f.Severity, f.Title, f.Description, f.Recommendation, f.Citations, string(f.Status), f.Assignee, f.DueDate, f.Resolution, f.ResolvedAt, f.ResolvedBy, f.UpdatedAt, ) return err } // ResolveFinding marks a finding as resolved with a resolution text func (s *Store) ResolveFinding(ctx context.Context, tenantID, id, resolution string, resolvedBy string) error { tid, err := uuid.Parse(tenantID) if err != nil { return fmt.Errorf("invalid tenant_id: %w", err) } fid, err := uuid.Parse(id) if err != nil { return fmt.Errorf("invalid finding id: %w", err) } now := time.Now().UTC() _, err = s.pool.Exec(ctx, ` UPDATE vendor_findings SET status = $3, resolution = $4, resolved_at = $5, resolved_by = $6, updated_at = $5 WHERE id = $1 AND tenant_id = $2 `, fid, tid, string(FindingStatusResolved), resolution, now, resolvedBy, ) return err } // ============================================================================ // Control Instance Operations // ============================================================================ // UpsertControlInstance inserts or updates a control instance func (s *Store) UpsertControlInstance(ctx context.Context, ci *ControlInstance) error { if ci.ID == uuid.Nil { ci.ID = uuid.New() } now := time.Now().UTC() ci.CreatedAt = now ci.UpdatedAt = now _, err := s.pool.Exec(ctx, ` INSERT INTO vendor_control_instances ( id, tenant_id, vendor_id, control_id, control_domain, status, evidence_ids, notes, last_assessed_at, last_assessed_by, next_assessment_date, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) ON CONFLICT (tenant_id, vendor_id, control_id) DO UPDATE SET status = EXCLUDED.status, evidence_ids = EXCLUDED.evidence_ids, notes = EXCLUDED.notes, last_assessed_at = EXCLUDED.last_assessed_at, last_assessed_by = EXCLUDED.last_assessed_by, next_assessment_date = EXCLUDED.next_assessment_date, updated_at = EXCLUDED.updated_at `, ci.ID, ci.TenantID, ci.VendorID, ci.ControlID, ci.ControlDomain, string(ci.Status), ci.EvidenceIDs, ci.Notes, ci.LastAssessedAt, ci.LastAssessedBy, ci.NextAssessmentDate, ci.CreatedAt, ci.UpdatedAt, ) return err } // ListControlInstances lists control instances for a vendor within a tenant func (s *Store) ListControlInstances(ctx context.Context, tenantID, vendorID string) ([]*ControlInstance, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } vid, err := uuid.Parse(vendorID) if err != nil { return nil, fmt.Errorf("invalid vendor_id: %w", err) } rows, err := s.pool.Query(ctx, ` SELECT id, tenant_id, vendor_id, control_id, control_domain, status, evidence_ids, notes, last_assessed_at, last_assessed_by, next_assessment_date, created_at, updated_at FROM vendor_control_instances WHERE tenant_id = $1 AND vendor_id = $2 ORDER BY control_id ASC `, tid, vid) if err != nil { return nil, err } defer rows.Close() var instances []*ControlInstance for rows.Next() { ci, err := scanControlInstance(rows) if err != nil { return nil, err } instances = append(instances, ci) } return instances, nil } // UpdateControlInstance updates an existing control instance func (s *Store) UpdateControlInstance(ctx context.Context, ci *ControlInstance) error { ci.UpdatedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE vendor_control_instances SET status = $3, evidence_ids = $4, notes = $5, last_assessed_at = $6, last_assessed_by = $7, next_assessment_date = $8, updated_at = $9 WHERE id = $1 AND tenant_id = $2 `, ci.ID, ci.TenantID, string(ci.Status), ci.EvidenceIDs, ci.Notes, ci.LastAssessedAt, ci.LastAssessedBy, ci.NextAssessmentDate, ci.UpdatedAt, ) return err } // ============================================================================ // Template Operations // ============================================================================ // CreateTemplate creates a new template func (s *Store) CreateTemplate(ctx context.Context, t *Template) error { if t.ID == uuid.Nil { t.ID = uuid.New() } t.CreatedAt = time.Now().UTC() t.UpdatedAt = t.CreatedAt _, err := s.pool.Exec(ctx, ` INSERT INTO compliance_templates ( id, tenant_id, template_type, template_id, category, name_de, name_en, description_de, description_en, template_data, industry, tags, is_system, is_active, usage_count, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 ) `, t.ID, t.TenantID, t.TemplateType, t.TemplateID, t.Category, t.NameDE, t.NameEN, t.DescriptionDE, t.DescriptionEN, t.TemplateData, t.Industry, t.Tags, t.IsSystem, t.IsActive, t.UsageCount, t.CreatedAt, t.UpdatedAt, ) return err } // GetTemplate retrieves a template by its template_id (not UUID) func (s *Store) GetTemplate(ctx context.Context, templateID string) (*Template, error) { var t Template err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, template_type, template_id, category, name_de, name_en, description_de, description_en, template_data, industry, tags, is_system, is_active, usage_count, created_at, updated_at FROM compliance_templates WHERE template_id = $1 `, templateID).Scan( &t.ID, &t.TenantID, &t.TemplateType, &t.TemplateID, &t.Category, &t.NameDE, &t.NameEN, &t.DescriptionDE, &t.DescriptionEN, &t.TemplateData, &t.Industry, &t.Tags, &t.IsSystem, &t.IsActive, &t.UsageCount, &t.CreatedAt, &t.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &t, nil } // ListTemplates lists templates filtered by type and optionally by category and industry func (s *Store) ListTemplates(ctx context.Context, templateType string, category *string, industry *string) ([]*Template, error) { query := ` SELECT id, tenant_id, template_type, template_id, category, name_de, name_en, description_de, description_en, template_data, industry, tags, is_system, is_active, usage_count, created_at, updated_at FROM compliance_templates WHERE template_type = $1` args := []interface{}{templateType} argIdx := 2 if category != nil { query += fmt.Sprintf(" AND category = $%d", argIdx) args = append(args, *category) argIdx++ } if industry != nil { query += fmt.Sprintf(" AND industry = $%d", argIdx) args = append(args, *industry) argIdx++ } query += " ORDER BY usage_count DESC, name_de ASC" rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var templates []*Template for rows.Next() { t, err := scanTemplate(rows) if err != nil { return nil, err } templates = append(templates, t) } return templates, nil } // IncrementTemplateUsage increments the usage count for a template func (s *Store) IncrementTemplateUsage(ctx context.Context, templateID string) error { _, err := s.pool.Exec(ctx, ` UPDATE compliance_templates SET usage_count = usage_count + 1, updated_at = NOW() WHERE template_id = $1 `, templateID) return err } // SeedSystemTemplates performs a bulk upsert of system templates func (s *Store) SeedSystemTemplates(ctx context.Context, templates []*Template) error { for _, t := range templates { t.IsSystem = true if t.ID == uuid.Nil { t.ID = uuid.New() } now := time.Now().UTC() t.CreatedAt = now t.UpdatedAt = now _, err := s.pool.Exec(ctx, ` INSERT INTO compliance_templates ( id, tenant_id, template_type, template_id, category, name_de, name_en, description_de, description_en, template_data, industry, tags, is_system, is_active, usage_count, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 ) ON CONFLICT (template_id) DO UPDATE SET name_de = EXCLUDED.name_de, name_en = EXCLUDED.name_en, description_de = EXCLUDED.description_de, description_en = EXCLUDED.description_en, template_data = EXCLUDED.template_data, category = EXCLUDED.category, industry = EXCLUDED.industry, tags = EXCLUDED.tags, updated_at = EXCLUDED.updated_at `, t.ID, t.TenantID, t.TemplateType, t.TemplateID, t.Category, t.NameDE, t.NameEN, t.DescriptionDE, t.DescriptionEN, t.TemplateData, t.Industry, t.Tags, t.IsSystem, t.IsActive, t.UsageCount, t.CreatedAt, t.UpdatedAt, ) if err != nil { return fmt.Errorf("failed to seed template %s: %w", t.TemplateID, err) } } return nil } // ============================================================================ // Statistics // ============================================================================ // GetVendorStats returns aggregated vendor statistics for a tenant func (s *Store) GetVendorStats(ctx context.Context, tenantID string) (*VendorStats, error) { tid, err := uuid.Parse(tenantID) if err != nil { return nil, fmt.Errorf("invalid tenant_id: %w", err) } stats := &VendorStats{ ByStatus: make(map[string]int), ByRole: make(map[string]int), ByRiskLevel: make(map[string]int), } // Total vendors s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1", tid).Scan(&stats.TotalVendors) // By status rows, err := s.pool.Query(ctx, "SELECT status, COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 GROUP BY status", tid) if err == nil { defer rows.Close() for rows.Next() { var status string var count int rows.Scan(&status, &count) stats.ByStatus[status] = count } } // By role rows, err = s.pool.Query(ctx, "SELECT role, COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 GROUP BY role", tid) if err == nil { defer rows.Close() for rows.Next() { var role string var count int rows.Scan(&role, &count) stats.ByRole[role] = count } } // By risk level (based on residual_risk_score thresholds) var riskCount int s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 AND residual_risk_score IS NOT NULL AND residual_risk_score <= 25 `, tid).Scan(&riskCount) stats.ByRiskLevel["LOW"] = riskCount s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 AND residual_risk_score IS NOT NULL AND residual_risk_score > 25 AND residual_risk_score <= 50 `, tid).Scan(&riskCount) stats.ByRiskLevel["MEDIUM"] = riskCount s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 AND residual_risk_score IS NOT NULL AND residual_risk_score > 50 AND residual_risk_score <= 75 `, tid).Scan(&riskCount) stats.ByRiskLevel["HIGH"] = riskCount s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 AND residual_risk_score IS NOT NULL AND residual_risk_score > 75 `, tid).Scan(&riskCount) stats.ByRiskLevel["CRITICAL"] = riskCount // Pending reviews (vendors past next_review_date) s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_vendors WHERE tenant_id = $1 AND next_review_date IS NOT NULL AND next_review_date < NOW() `, tid).Scan(&stats.PendingReviews) // Expired contracts s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM vendor_contracts WHERE tenant_id = $1 AND expiration_date IS NOT NULL AND expiration_date < NOW() `, tid).Scan(&stats.ExpiredContracts) return stats, nil } // ============================================================================ // Row Scanning Helpers // ============================================================================ // scanVendor scans a vendor row from pgx.Rows func scanVendor(rows pgx.Rows) (*Vendor, error) { var v Vendor var role, status string err := rows.Scan( &v.ID, &v.TenantID, &v.Name, &v.LegalForm, &v.Country, &v.Address, &v.Website, &v.ContactName, &v.ContactEmail, &v.ContactPhone, &v.ContactDepartment, &role, &v.ServiceCategory, &v.ServiceDescription, &v.DataAccessLevel, &v.ProcessingLocations, &v.Certifications, &v.InherentRiskScore, &v.ResidualRiskScore, &v.ManualRiskAdjustment, &v.ReviewFrequency, &v.LastReviewDate, &v.NextReviewDate, &v.ProcessingActivityIDs, &status, &v.TemplateID, &v.CreatedAt, &v.UpdatedAt, &v.CreatedBy, ) if err != nil { return nil, err } v.Role = VendorRole(role) v.Status = VendorStatus(status) return &v, nil } // scanContract scans a contract row from pgx.Rows func scanContract(rows pgx.Rows) (*Contract, error) { var c Contract var documentType string err := rows.Scan( &c.ID, &c.TenantID, &c.VendorID, &c.FileName, &c.OriginalName, &c.MimeType, &c.FileSize, &c.StoragePath, &documentType, &c.Parties, &c.EffectiveDate, &c.ExpirationDate, &c.AutoRenewal, &c.RenewalNoticePeriod, &c.ReviewStatus, &c.ReviewCompletedAt, &c.ComplianceScore, &c.Version, &c.PreviousVersionID, &c.ExtractedText, &c.PageCount, &c.CreatedAt, &c.UpdatedAt, &c.CreatedBy, ) if err != nil { return nil, err } c.DocumentType = DocumentType(documentType) return &c, nil } // scanFinding scans a finding row from pgx.Rows func scanFinding(rows pgx.Rows) (*Finding, error) { var f Finding var findingType, status string err := rows.Scan( &f.ID, &f.TenantID, &f.ContractID, &f.VendorID, &findingType, &f.Category, &f.Severity, &f.Title, &f.Description, &f.Recommendation, &f.Citations, &status, &f.Assignee, &f.DueDate, &f.Resolution, &f.ResolvedAt, &f.ResolvedBy, &f.CreatedAt, &f.UpdatedAt, ) if err != nil { return nil, err } f.FindingType = FindingType(findingType) f.Status = FindingStatus(status) return &f, nil } // scanControlInstance scans a control instance row from pgx.Rows func scanControlInstance(rows pgx.Rows) (*ControlInstance, error) { var ci ControlInstance var status string err := rows.Scan( &ci.ID, &ci.TenantID, &ci.VendorID, &ci.ControlID, &ci.ControlDomain, &status, &ci.EvidenceIDs, &ci.Notes, &ci.LastAssessedAt, &ci.LastAssessedBy, &ci.NextAssessmentDate, &ci.CreatedAt, &ci.UpdatedAt, ) if err != nil { return nil, err } ci.Status = ControlStatus(status) return &ci, nil } // scanTemplate scans a template row from pgx.Rows func scanTemplate(rows pgx.Rows) (*Template, error) { var t Template err := rows.Scan( &t.ID, &t.TenantID, &t.TemplateType, &t.TemplateID, &t.Category, &t.NameDE, &t.NameEN, &t.DescriptionDE, &t.DescriptionEN, &t.TemplateData, &t.Industry, &t.Tags, &t.IsSystem, &t.IsActive, &t.UsageCount, &t.CreatedAt, &t.UpdatedAt, ) if err != nil { return nil, err } return &t, nil }