Files
breakpilot-compliance/ai-compliance-sdk/internal/vendor/store.go
Benjamin Boenisch 504dd3591b feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends
Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:11:27 +01:00

1117 lines
30 KiB
Go

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
}