From 123e5c36b7b70ed8c59411855c837dde64c9014f Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 17 Mar 2026 00:38:11 +0100 Subject: [PATCH] Add finding service layer Introduce FindingService with create, update, delete, get, and list operations including audit association management. Update the probo service orchestration, actions, and policies to use findings. Remove the old NonconformityService and ContinualImprovementService. Signed-off-by: Bryan Frimin --- pkg/probo/actions.go | 23 +- pkg/probo/audit_service.go | 83 ++++ pkg/probo/continual_improvement_service.go | 298 ------------- pkg/probo/finding_service.go | 476 +++++++++++++++++++++ pkg/probo/nonconformity_service.go | 323 -------------- pkg/probo/policies.go | 8 +- pkg/probo/service.go | 8 +- 7 files changed, 574 insertions(+), 645 deletions(-) delete mode 100644 pkg/probo/continual_improvement_service.go create mode 100644 pkg/probo/finding_service.go delete mode 100644 pkg/probo/nonconformity_service.go diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 19564b648..96faea13c 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -242,12 +242,14 @@ const ( ActionReportGetReportUrl = "core:report:get-report-url" ActionReportDownloadUrlGet = "core:report:get-download-url" - // Nonconformity actions - ActionNonconformityGet = "core:nonconformity:get" - ActionNonconformityList = "core:nonconformity:list" - ActionNonconformityCreate = "core:nonconformity:create" - ActionNonconformityUpdate = "core:nonconformity:update" - ActionNonconformityDelete = "core:nonconformity:delete" + // Finding actions + ActionFindingGet = "core:finding:get" + ActionFindingList = "core:finding:list" + ActionFindingCreate = "core:finding:create" + ActionFindingUpdate = "core:finding:update" + ActionFindingDelete = "core:finding:delete" + ActionFindingAuditMappingCreate = "core:finding:create-audit-mapping" + ActionFindingAuditMappingDelete = "core:finding:delete-audit-mapping" // Obligation actions ActionObligationGet = "core:obligation:get" @@ -256,13 +258,6 @@ const ( ActionObligationUpdate = "core:obligation:update" ActionObligationDelete = "core:obligation:delete" - // ContinualImprovement actions - ActionContinualImprovementGet = "core:continual-improvement:get" - ActionContinualImprovementList = "core:continual-improvement:list" - ActionContinualImprovementCreate = "core:continual-improvement:create" - ActionContinualImprovementUpdate = "core:continual-improvement:update" - ActionContinualImprovementDelete = "core:continual-improvement:delete" - // ProcessingActivity actions ActionProcessingActivityList = "core:processing-activity:list" ActionProcessingActivityGet = "core:processing-activity:get" diff --git a/pkg/probo/audit_service.go b/pkg/probo/audit_service.go index 5c61ee265..4118b5e68 100644 --- a/pkg/probo/audit_service.go +++ b/pkg/probo/audit_service.go @@ -485,3 +485,86 @@ func (s AuditService) ListForControlID( return page.NewPage(audits, cursor), nil } + +func (s AuditService) CountForControlID( + ctx context.Context, + controlID gid.GID, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + audits := coredata.Audits{} + count, err = audits.CountByControlID(ctx, conn, s.svc.scope, controlID) + if err != nil { + return fmt.Errorf("cannot count audits: %w", err) + } + + return nil + }, + ) + + if err != nil { + return 0, err + } + + return count, nil +} + +func (s AuditService) CountForFindingID( + ctx context.Context, + findingID gid.GID, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + audits := coredata.Audits{} + count, err = audits.CountByFindingID(ctx, conn, s.svc.scope, findingID) + if err != nil { + return fmt.Errorf("cannot count audits: %w", err) + } + + return nil + }, + ) + + if err != nil { + return 0, err + } + + return count, nil +} + +func (s AuditService) ListForFindingID( + ctx context.Context, + findingID gid.GID, + cursor *page.Cursor[coredata.AuditOrderField], +) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) { + var audits coredata.Audits + finding := &coredata.Finding{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := finding.LoadByID(ctx, conn, s.svc.scope, findingID); err != nil { + return fmt.Errorf("cannot load finding: %w", err) + } + + err := audits.LoadByFindingID(ctx, conn, s.svc.scope, finding.ID, cursor) + if err != nil { + return fmt.Errorf("cannot load audits: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return page.NewPage(audits, cursor), nil +} diff --git a/pkg/probo/continual_improvement_service.go b/pkg/probo/continual_improvement_service.go deleted file mode 100644 index af08547e1..000000000 --- a/pkg/probo/continual_improvement_service.go +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package probo - -import ( - "context" - "fmt" - "time" - - "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/validator" -) - -type ContinualImprovementService struct { - svc *TenantService -} - -type ( - CreateContinualImprovementRequest struct { - OrganizationID gid.GID - ReferenceID string - Description *string - Source *string - OwnerID gid.GID - TargetDate *time.Time - Status *coredata.ContinualImprovementStatus - Priority *coredata.ContinualImprovementPriority - } - - UpdateContinualImprovementRequest struct { - ID gid.GID - ReferenceID *string - Description **string - Source **string - OwnerID *gid.GID - TargetDate **time.Time - Status *coredata.ContinualImprovementStatus - Priority *coredata.ContinualImprovementPriority - } -) - -func (ccir *CreateContinualImprovementRequest) Validate() error { - v := validator.New() - - v.Check(ccir.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) - v.Check(ccir.ReferenceID, "reference_id", validator.SafeText(NameMaxLength)) - v.Check(ccir.Description, "description", validator.SafeText(ContentMaxLength)) - v.Check(ccir.Source, "source", validator.SafeText(ContentMaxLength)) - v.Check(ccir.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType)) - v.Check(ccir.Status, "status", validator.OneOfSlice(coredata.ContinualImprovementStatuses())) - v.Check(ccir.Priority, "priority", validator.OneOfSlice(coredata.ContinualImprovementPriorities())) - - return v.Error() -} - -func (ucir *UpdateContinualImprovementRequest) Validate() error { - v := validator.New() - - v.Check(ucir.ID, "id", validator.Required(), validator.GID(coredata.ContinualImprovementEntityType)) - v.Check(ucir.ReferenceID, "reference_id", validator.SafeText(NameMaxLength)) - v.Check(ucir.Description, "description", validator.SafeText(ContentMaxLength)) - v.Check(ucir.Source, "source", validator.SafeText(ContentMaxLength)) - v.Check(ucir.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType)) - v.Check(ucir.Status, "status", validator.OneOfSlice(coredata.ContinualImprovementStatuses())) - v.Check(ucir.Priority, "priority", validator.OneOfSlice(coredata.ContinualImprovementPriorities())) - - return v.Error() -} - -func (s ContinualImprovementService) Get( - ctx context.Context, - continualImprovementID gid.GID, -) (*coredata.ContinualImprovement, error) { - improvement := &coredata.ContinualImprovement{} - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) error { - if err := improvement.LoadByID(ctx, conn, s.svc.scope, continualImprovementID); err != nil { - return fmt.Errorf("cannot load continual improvement: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return improvement, nil -} - -func (s *ContinualImprovementService) Create( - ctx context.Context, - req *CreateContinualImprovementRequest, -) (*coredata.ContinualImprovement, error) { - if err := req.Validate(); err != nil { - return nil, err - } - - now := time.Now() - - improvement := &coredata.ContinualImprovement{ - ID: gid.New(s.svc.scope.GetTenantID(), coredata.ContinualImprovementEntityType), - OrganizationID: req.OrganizationID, - ReferenceID: req.ReferenceID, - Description: req.Description, - Source: req.Source, - OwnerID: req.OwnerID, - TargetDate: req.TargetDate, - Status: *req.Status, - Priority: *req.Priority, - CreatedAt: now, - UpdatedAt: now, - } - - err := s.svc.pg.WithTx( - ctx, - func(conn pg.Conn) error { - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - owner := &coredata.MembershipProfile{} - if err := owner.LoadByID(ctx, conn, s.svc.scope, req.OwnerID); err != nil { - return fmt.Errorf("cannot load owner profile: %w", err) - } - - if err := improvement.Insert(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot insert continual improvement: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return improvement, nil -} - -func (s *ContinualImprovementService) Update( - ctx context.Context, - req *UpdateContinualImprovementRequest, -) (*coredata.ContinualImprovement, error) { - improvement := &coredata.ContinualImprovement{} - - err := s.svc.pg.WithTx( - ctx, - func(conn pg.Conn) error { - if err := improvement.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil { - return fmt.Errorf("cannot load continual improvement: %w", err) - } - - if req.ReferenceID != nil { - improvement.ReferenceID = *req.ReferenceID - } - - if req.Description != nil { - improvement.Description = *req.Description - } - - if req.Source != nil { - improvement.Source = *req.Source - } - - if req.OwnerID != nil { - owner := &coredata.MembershipProfile{} - if err := owner.LoadByID(ctx, conn, s.svc.scope, *req.OwnerID); err != nil { - return fmt.Errorf("cannot load owner profile: %w", err) - } - improvement.OwnerID = *req.OwnerID - } - - if req.TargetDate != nil { - improvement.TargetDate = *req.TargetDate - } - - if req.Status != nil { - improvement.Status = *req.Status - } - - if req.Priority != nil { - improvement.Priority = *req.Priority - } - - improvement.UpdatedAt = time.Now() - - if err := improvement.Update(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot update continual improvement: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return improvement, nil -} - -func (s *ContinualImprovementService) Delete( - ctx context.Context, - continualImprovementID gid.GID, -) error { - err := s.svc.pg.WithTx( - ctx, - func(conn pg.Conn) error { - improvement := &coredata.ContinualImprovement{} - if err := improvement.LoadByID(ctx, conn, s.svc.scope, continualImprovementID); err != nil { - return fmt.Errorf("cannot load continual improvement: %w", err) - } - - if err := improvement.Delete(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot delete continual improvement: %w", err) - } - - return nil - }, - ) - - return err -} - -func (s ContinualImprovementService) CountByOrganizationID( - ctx context.Context, - organizationID gid.GID, - filter *coredata.ContinualImprovementFilter, -) (int, error) { - var count int - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) (err error) { - improvements := coredata.ContinualImprovements{} - count, err = improvements.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) - if err != nil { - return fmt.Errorf("cannot count continual improvements: %w", err) - } - - return nil - }, - ) - - if err != nil { - return 0, err - } - - return count, nil -} - -func (s ContinualImprovementService) ListForOrganizationID( - ctx context.Context, - organizationID gid.GID, - cursor *page.Cursor[coredata.ContinualImprovementOrderField], - filter *coredata.ContinualImprovementFilter, -) (*page.Page[*coredata.ContinualImprovement, coredata.ContinualImprovementOrderField], error) { - var improvements coredata.ContinualImprovements - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) error { - err := improvements.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) - if err != nil { - return fmt.Errorf("cannot load continual improvements: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return page.NewPage(improvements, cursor), nil -} diff --git a/pkg/probo/finding_service.go b/pkg/probo/finding_service.go new file mode 100644 index 000000000..77a9eb6da --- /dev/null +++ b/pkg/probo/finding_service.go @@ -0,0 +1,476 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package probo + +import ( + "context" + "fmt" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/validator" +) + +type FindingService struct { + svc *TenantService +} + +type ( + CreateFindingRequest struct { + OrganizationID gid.GID + Kind coredata.FindingKind + Description *string + Source *string + IdentifiedOn *time.Time + RootCause *string + CorrectiveAction *string + OwnerID *gid.GID + DueDate *time.Time + Status *coredata.FindingStatus + Priority *coredata.FindingPriority + RiskID *gid.GID + EffectivenessCheck *string + } + + UpdateFindingRequest struct { + ID gid.GID + Description **string + Source **string + IdentifiedOn **time.Time + RootCause **string + CorrectiveAction **string + OwnerID *gid.GID + DueDate **time.Time + Status *coredata.FindingStatus + Priority *coredata.FindingPriority + RiskID **gid.GID + EffectivenessCheck **string + } +) + +func (r *CreateFindingRequest) Validate() error { + v := validator.New() + + v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) + v.Check(r.Kind, "kind", validator.Required(), validator.OneOfSlice(coredata.FindingKinds())) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + v.Check(r.Source, "source", validator.SafeText(ContentMaxLength)) + v.Check(r.RootCause, "root_cause", validator.SafeText(ContentMaxLength)) + v.Check(r.CorrectiveAction, "corrective_action", validator.SafeText(ContentMaxLength)) + v.Check(r.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType)) + v.Check(r.Status, "status", validator.OneOfSlice(coredata.FindingStatuses())) + v.Check(r.Priority, "priority", validator.OneOfSlice(coredata.FindingPriorities())) + v.Check(r.RiskID, "risk_id", validator.GID(coredata.RiskEntityType)) + v.Check(r.EffectivenessCheck, "effectiveness_check", validator.SafeText(ContentMaxLength)) + + if r.Status != nil && *r.Status == coredata.FindingStatusRiskAccepted { + v.Check(r.RiskID, "risk_id", validator.Required()) + } + + return v.Error() +} + +func (r *UpdateFindingRequest) Validate() error { + v := validator.New() + + v.Check(r.ID, "id", validator.Required(), validator.GID(coredata.FindingEntityType)) + v.Check(r.Description, "description", validator.SafeText(ContentMaxLength)) + v.Check(r.Source, "source", validator.SafeText(ContentMaxLength)) + v.Check(r.RootCause, "root_cause", validator.SafeText(ContentMaxLength)) + v.Check(r.CorrectiveAction, "corrective_action", validator.SafeText(ContentMaxLength)) + v.Check(r.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType)) + v.Check(r.Status, "status", validator.OneOfSlice(coredata.FindingStatuses())) + v.Check(r.Priority, "priority", validator.OneOfSlice(coredata.FindingPriorities())) + v.Check(r.RiskID, "risk_id", validator.GID(coredata.RiskEntityType)) + v.Check(r.EffectivenessCheck, "effectiveness_check", validator.SafeText(ContentMaxLength)) + + return v.Error() +} + +func (s FindingService) Get( + ctx context.Context, + findingID gid.GID, +) (*coredata.Finding, error) { + finding := &coredata.Finding{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return finding.LoadByID(ctx, conn, s.svc.scope, findingID) + }, + ) + + if err != nil { + return nil, fmt.Errorf("cannot get finding: %w", err) + } + + return finding, nil +} + +func (s *FindingService) Create( + ctx context.Context, + req *CreateFindingRequest, +) (*coredata.Finding, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + now := time.Now() + + finding := &coredata.Finding{ + ID: gid.New(s.svc.scope.GetTenantID(), coredata.FindingEntityType), + OrganizationID: req.OrganizationID, + Kind: req.Kind, + Description: req.Description, + Source: req.Source, + IdentifiedOn: req.IdentifiedOn, + RootCause: req.RootCause, + CorrectiveAction: req.CorrectiveAction, + OwnerID: req.OwnerID, + DueDate: req.DueDate, + Status: coredata.FindingStatusOpen, + Priority: coredata.FindingPriorityMedium, + RiskID: req.RiskID, + EffectivenessCheck: req.EffectivenessCheck, + CreatedAt: now, + UpdatedAt: now, + } + + if req.Status != nil { + finding.Status = *req.Status + } + + if req.Priority != nil { + finding.Priority = *req.Priority + } + + err := s.svc.pg.WithTx( + ctx, + func(conn pg.Conn) error { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + if req.OwnerID != nil { + owner := &coredata.MembershipProfile{} + if err := owner.LoadByID(ctx, conn, s.svc.scope, *req.OwnerID); err != nil { + return fmt.Errorf("cannot load owner profile: %w", err) + } + } + + if err := finding.Insert(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot insert finding: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return finding, nil +} + +func (s *FindingService) Update( + ctx context.Context, + req *UpdateFindingRequest, +) (*coredata.Finding, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + finding := &coredata.Finding{} + + err := s.svc.pg.WithTx( + ctx, + func(conn pg.Conn) error { + if err := finding.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil { + return fmt.Errorf("cannot load finding: %w", err) + } + + if req.Description != nil { + finding.Description = *req.Description + } + if req.Source != nil { + finding.Source = *req.Source + } + if req.IdentifiedOn != nil { + finding.IdentifiedOn = *req.IdentifiedOn + } + if req.RootCause != nil { + finding.RootCause = *req.RootCause + } + if req.CorrectiveAction != nil { + finding.CorrectiveAction = *req.CorrectiveAction + } + if req.OwnerID != nil { + owner := &coredata.MembershipProfile{} + if err := owner.LoadByID(ctx, conn, s.svc.scope, *req.OwnerID); err != nil { + return fmt.Errorf("cannot load owner profile: %w", err) + } + finding.OwnerID = req.OwnerID + } + if req.DueDate != nil { + finding.DueDate = *req.DueDate + } + if req.Status != nil { + finding.Status = *req.Status + } + if req.Priority != nil { + finding.Priority = *req.Priority + } + if req.RiskID != nil { + finding.RiskID = *req.RiskID + } + if req.EffectivenessCheck != nil { + finding.EffectivenessCheck = *req.EffectivenessCheck + } + + if finding.Status == coredata.FindingStatusRiskAccepted && finding.RiskID == nil { + return fmt.Errorf("cannot update finding: risk_id is required when status is RISK_ACCEPTED") + } + + finding.UpdatedAt = time.Now() + + if err := finding.Update(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot update finding: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return finding, nil +} + +func (s FindingService) Delete( + ctx context.Context, + findingID gid.GID, +) error { + finding := coredata.Finding{ID: findingID} + return s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := finding.Delete(ctx, conn, s.svc.scope) + if err != nil { + return fmt.Errorf("cannot delete finding: %w", err) + } + return nil + }, + ) +} + +func (s FindingService) ListForOrganizationID( + ctx context.Context, + organizationID gid.GID, + cursor *page.Cursor[coredata.FindingOrderField], + filter *coredata.FindingFilter, +) (*page.Page[*coredata.Finding, coredata.FindingOrderField], error) { + var findings coredata.Findings + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := findings.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) + if err != nil { + return fmt.Errorf("cannot load findings: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return page.NewPage(findings, cursor), nil +} + +func (s FindingService) CountForOrganizationID( + ctx context.Context, + organizationID gid.GID, + filter *coredata.FindingFilter, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + findings := coredata.Findings{} + count, err = findings.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) + if err != nil { + return fmt.Errorf("cannot count findings: %w", err) + } + + return nil + }, + ) + + if err != nil { + return 0, err + } + + return count, nil +} + +func (s FindingService) CreateAuditMapping( + ctx context.Context, + findingID gid.GID, + auditID gid.GID, + referenceID string, +) (*coredata.Finding, *coredata.Audit, error) { + finding := &coredata.Finding{} + audit := &coredata.Audit{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := finding.LoadByID(ctx, conn, s.svc.scope, findingID); err != nil { + return fmt.Errorf("cannot load finding: %w", err) + } + + if err := audit.LoadByID(ctx, conn, s.svc.scope, auditID); err != nil { + return fmt.Errorf("cannot load audit: %w", err) + } + + if finding.OrganizationID != audit.OrganizationID { + return fmt.Errorf("cannot create finding audit mapping: finding and audit belong to different organizations") + } + + findingAudit := &coredata.FindingAudit{ + FindingID: findingID, + AuditID: auditID, + ReferenceID: referenceID, + OrganizationID: finding.OrganizationID, + CreatedAt: time.Now(), + } + + if err := findingAudit.Upsert(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot create finding audit mapping: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, nil, err + } + + return finding, audit, nil +} + +func (s FindingService) DeleteAuditMapping( + ctx context.Context, + findingID gid.GID, + auditID gid.GID, +) (*coredata.Finding, *coredata.Audit, error) { + finding := &coredata.Finding{} + audit := &coredata.Audit{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := finding.LoadByID(ctx, conn, s.svc.scope, findingID); err != nil { + return fmt.Errorf("cannot load finding: %w", err) + } + + if err := audit.LoadByID(ctx, conn, s.svc.scope, auditID); err != nil { + return fmt.Errorf("cannot load audit: %w", err) + } + + findingAudit := &coredata.FindingAudit{} + if err := findingAudit.Delete(ctx, conn, s.svc.scope, finding.ID, audit.ID); err != nil { + return fmt.Errorf("cannot delete finding audit mapping: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, nil, fmt.Errorf("cannot delete finding audit mapping: %w", err) + } + + return finding, audit, nil +} + +func (s FindingService) ListForAuditID( + ctx context.Context, + auditID gid.GID, + cursor *page.Cursor[coredata.FindingOrderField], + filter *coredata.FindingFilter, +) (*page.Page[*coredata.Finding, coredata.FindingOrderField], error) { + var findings coredata.Findings + audit := &coredata.Audit{} + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := audit.LoadByID(ctx, conn, s.svc.scope, auditID); err != nil { + return fmt.Errorf("cannot load audit: %w", err) + } + if err := findings.LoadByAuditID(ctx, conn, s.svc.scope, auditID, cursor, filter); err != nil { + return fmt.Errorf("cannot load findings: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, err + } + + return page.NewPage([]*coredata.Finding(findings), cursor), nil +} + +func (s FindingService) CountForAuditID( + ctx context.Context, + auditID gid.GID, + filter *coredata.FindingFilter, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + findings := coredata.Findings{} + count, err = findings.CountByAuditID(ctx, conn, s.svc.scope, auditID, filter) + if err != nil { + return fmt.Errorf("cannot count findings: %w", err) + } + + return nil + }, + ) + + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/pkg/probo/nonconformity_service.go b/pkg/probo/nonconformity_service.go deleted file mode 100644 index ce9664754..000000000 --- a/pkg/probo/nonconformity_service.go +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package probo - -import ( - "context" - "fmt" - "time" - - "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/page" - "go.probo.inc/probo/pkg/validator" -) - -type NonconformityService struct { - svc *TenantService -} - -type ( - CreateNonconformityRequest struct { - OrganizationID gid.GID - ReferenceID string - Description *string - AuditID *gid.GID - DateIdentified *time.Time - RootCause string - CorrectiveAction *string - OwnerID gid.GID - DueDate *time.Time - Status *coredata.NonconformityStatus - EffectivenessCheck *string - } - - UpdateNonconformityRequest struct { - ID gid.GID - ReferenceID *string - Description **string - DateIdentified **time.Time - RootCause *string - CorrectiveAction **string - OwnerID *gid.GID - AuditID **gid.GID - DueDate **time.Time - Status *coredata.NonconformityStatus - EffectivenessCheck **string - } -) - -func (cnr *CreateNonconformityRequest) Validate() error { - v := validator.New() - - v.Check(cnr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) - v.Check(cnr.ReferenceID, "reference_id", validator.Required(), validator.SafeText(NameMaxLength)) - v.Check(cnr.Description, "description", validator.SafeText(ContentMaxLength)) - v.Check(cnr.AuditID, "audit_id", validator.GID(coredata.AuditEntityType)) - v.Check(cnr.RootCause, "root_cause", validator.Required(), validator.SafeText(ContentMaxLength)) - v.Check(cnr.CorrectiveAction, "corrective_action", validator.SafeText(ContentMaxLength)) - v.Check(cnr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType)) - v.Check(cnr.Status, "status", validator.OneOfSlice(coredata.NonconformityStatuses())) - v.Check(cnr.EffectivenessCheck, "effectiveness_check", validator.SafeText(ContentMaxLength)) - - return v.Error() -} - -func (unr *UpdateNonconformityRequest) Validate() error { - v := validator.New() - - v.Check(unr.ID, "id", validator.Required(), validator.GID(coredata.NonconformityEntityType)) - v.Check(unr.ReferenceID, "reference_id", validator.SafeText(NameMaxLength)) - v.Check(unr.Description, "description", validator.SafeText(ContentMaxLength)) - v.Check(unr.RootCause, "root_cause", validator.SafeText(ContentMaxLength)) - v.Check(unr.CorrectiveAction, "corrective_action", validator.SafeText(ContentMaxLength)) - v.Check(unr.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType)) - v.Check(unr.Status, "status", validator.OneOfSlice(coredata.NonconformityStatuses())) - v.Check(unr.EffectivenessCheck, "effectiveness_check", validator.SafeText(ContentMaxLength)) - - return v.Error() -} -func (s NonconformityService) Get( - ctx context.Context, - nonconformityID gid.GID, -) (*coredata.Nonconformity, error) { - nonconformity := &coredata.Nonconformity{} - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) error { - return nonconformity.LoadByID(ctx, conn, s.svc.scope, nonconformityID) - }, - ) - - if err != nil { - return nil, err - } - - return nonconformity, nil -} - -func (s *NonconformityService) Create( - ctx context.Context, - req *CreateNonconformityRequest, -) (*coredata.Nonconformity, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - - now := time.Now() - - nonconformity := &coredata.Nonconformity{ - ID: gid.New(s.svc.scope.GetTenantID(), coredata.NonconformityEntityType), - OrganizationID: req.OrganizationID, - ReferenceID: req.ReferenceID, - Description: req.Description, - AuditID: req.AuditID, - DateIdentified: req.DateIdentified, - RootCause: req.RootCause, - CorrectiveAction: req.CorrectiveAction, - OwnerID: req.OwnerID, - DueDate: req.DueDate, - Status: coredata.NonconformityStatusOpen, - EffectivenessCheck: req.EffectivenessCheck, - CreatedAt: now, - UpdatedAt: now, - } - - if req.Status != nil { - nonconformity.Status = *req.Status - } - - err := s.svc.pg.WithTx( - ctx, - func(conn pg.Conn) error { - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - if req.AuditID != nil { - audit := &coredata.Audit{} - if err := audit.LoadByID(ctx, conn, s.svc.scope, *req.AuditID); err != nil { - return fmt.Errorf("cannot load audit: %w", err) - } - } - - owner := &coredata.MembershipProfile{} - if err := owner.LoadByID(ctx, conn, s.svc.scope, req.OwnerID); err != nil { - return fmt.Errorf("cannot load owner profile: %w", err) - } - - if err := nonconformity.Insert(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot insert nonconformity: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return nonconformity, nil -} - -func (s *NonconformityService) Update( - ctx context.Context, - req *UpdateNonconformityRequest, -) (*coredata.Nonconformity, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - - nonconformity := &coredata.Nonconformity{} - - err := s.svc.pg.WithTx( - ctx, - func(conn pg.Conn) error { - if err := nonconformity.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil { - return fmt.Errorf("cannot load nonconformity: %w", err) - } - - if req.ReferenceID != nil { - nonconformity.ReferenceID = *req.ReferenceID - } - if req.Description != nil { - nonconformity.Description = *req.Description - } - if req.DateIdentified != nil { - nonconformity.DateIdentified = *req.DateIdentified - } - if req.RootCause != nil { - nonconformity.RootCause = *req.RootCause - } - if req.CorrectiveAction != nil { - nonconformity.CorrectiveAction = *req.CorrectiveAction - } - if req.OwnerID != nil { - owner := &coredata.MembershipProfile{} - if err := owner.LoadByID(ctx, conn, s.svc.scope, *req.OwnerID); err != nil { - return fmt.Errorf("cannot load owner profile: %w", err) - } - nonconformity.OwnerID = *req.OwnerID - } - if req.AuditID != nil { - if *req.AuditID != nil { - audit := &coredata.Audit{} - if err := audit.LoadByID(ctx, conn, s.svc.scope, **req.AuditID); err != nil { - return fmt.Errorf("cannot load audit: %w", err) - } - } - nonconformity.AuditID = *req.AuditID - } - if req.DueDate != nil { - nonconformity.DueDate = *req.DueDate - } - if req.Status != nil { - nonconformity.Status = *req.Status - } - if req.EffectivenessCheck != nil { - nonconformity.EffectivenessCheck = *req.EffectivenessCheck - } - - nonconformity.UpdatedAt = time.Now() - - if err := nonconformity.Update(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot update nonconformity: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return nonconformity, nil -} - -func (s NonconformityService) Delete( - ctx context.Context, - nonconformityID gid.GID, -) error { - nonconformity := coredata.Nonconformity{ID: nonconformityID} - return s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) error { - err := nonconformity.Delete(ctx, conn, s.svc.scope) - if err != nil { - return fmt.Errorf("cannot delete nonconformity: %w", err) - } - return nil - }, - ) -} - -func (s NonconformityService) ListForOrganizationID( - ctx context.Context, - organizationID gid.GID, - cursor *page.Cursor[coredata.NonconformityOrderField], - filter *coredata.NonconformityFilter, -) (*page.Page[*coredata.Nonconformity, coredata.NonconformityOrderField], error) { - var nonconformities coredata.Nonconformities - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) error { - err := nonconformities.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter) - if err != nil { - return fmt.Errorf("cannot load nonconformities: %w", err) - } - - return nil - }, - ) - - if err != nil { - return nil, err - } - - return page.NewPage(nonconformities, cursor), nil -} - -func (s NonconformityService) CountForOrganizationID( - ctx context.Context, - organizationID gid.GID, - filter *coredata.NonconformityFilter, -) (int, error) { - var count int - - err := s.svc.pg.WithConn( - ctx, - func(conn pg.Conn) (err error) { - nonconformities := coredata.Nonconformities{} - count, err = nonconformities.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter) - if err != nil { - return fmt.Errorf("cannot count nonconformities: %w", err) - } - - return nil - }, - ) - - if err != nil { - return 0, err - } - - return count, nil -} diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 075e1dd9c..5d01afabf 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -66,9 +66,8 @@ var ViewerPolicy = policy.NewPolicy( ActionDatumGet, ActionDatumList, ActionAuditGet, ActionAuditList, ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet, - ActionNonconformityGet, ActionNonconformityList, + ActionFindingGet, ActionFindingList, ActionObligationGet, ActionObligationList, - ActionContinualImprovementGet, ActionContinualImprovementList, ActionProcessingActivityGet, ActionProcessingActivityList, ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList, ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList, @@ -134,9 +133,8 @@ var AuditorPolicy = policy.NewPolicy( ActionDatumGet, ActionDatumList, ActionAuditGet, ActionAuditList, ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet, - ActionNonconformityGet, ActionNonconformityList, + ActionFindingGet, ActionFindingList, ActionObligationGet, ActionObligationList, - ActionContinualImprovementGet, ActionContinualImprovementList, ActionProcessingActivityGet, ActionProcessingActivityList, ActionDataProtectionImpactAssessmentGet, ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList, diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 3511cf5ba..b2d670c36 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -107,10 +107,9 @@ type ( TrustCenterFiles *TrustCenterFileService ComplianceFrameworks *ComplianceFrameworkService ComplianceExternalURLs *ComplianceExternalURLService - Nonconformities *NonconformityService + Findings *FindingService Obligations *ObligationService Snapshots *SnapshotService - ContinualImprovements *ContinualImprovementService RightsRequests *RightsRequestService ProcessingActivities *ProcessingActivityService DataProtectionImpactAssessments *DataProtectionImpactAssessmentService @@ -257,10 +256,9 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { filevalidation.WithMaxFileSize(10*1024*1024), // 10MB ), } - tenantService.Nonconformities = &NonconformityService{svc: tenantService} + tenantService.Findings = &FindingService{svc: tenantService} tenantService.Obligations = &ObligationService{svc: tenantService} tenantService.Snapshots = &SnapshotService{svc: tenantService} - tenantService.ContinualImprovements = &ContinualImprovementService{svc: tenantService} tenantService.RightsRequests = &RightsRequestService{svc: tenantService} tenantService.ProcessingActivities = &ProcessingActivityService{ svc: tenantService,