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>
1117 lines
30 KiB
Go
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
|
|
}
|