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 <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-17 00:38:11 +01:00
parent bf276cd3db
commit 123e5c36b7
7 changed files with 574 additions and 645 deletions

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -242,12 +242,14 @@ const (
ActionReportGetReportUrl = "core:report:get-report-url" ActionReportGetReportUrl = "core:report:get-report-url"
ActionReportDownloadUrlGet = "core:report:get-download-url" ActionReportDownloadUrlGet = "core:report:get-download-url"
// Nonconformity actions // Finding actions
ActionNonconformityGet = "core:nonconformity:get" ActionFindingGet = "core:finding:get"
ActionNonconformityList = "core:nonconformity:list" ActionFindingList = "core:finding:list"
ActionNonconformityCreate = "core:nonconformity:create" ActionFindingCreate = "core:finding:create"
ActionNonconformityUpdate = "core:nonconformity:update" ActionFindingUpdate = "core:finding:update"
ActionNonconformityDelete = "core:nonconformity:delete" ActionFindingDelete = "core:finding:delete"
ActionFindingAuditMappingCreate = "core:finding:create-audit-mapping"
ActionFindingAuditMappingDelete = "core:finding:delete-audit-mapping"
// Obligation actions // Obligation actions
ActionObligationGet = "core:obligation:get" ActionObligationGet = "core:obligation:get"
@@ -256,13 +258,6 @@ const (
ActionObligationUpdate = "core:obligation:update" ActionObligationUpdate = "core:obligation:update"
ActionObligationDelete = "core:obligation:delete" 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 // ProcessingActivity actions
ActionProcessingActivityList = "core:processing-activity:list" ActionProcessingActivityList = "core:processing-activity:list"
ActionProcessingActivityGet = "core:processing-activity:get" ActionProcessingActivityGet = "core:processing-activity:get"

View File

@@ -485,3 +485,86 @@ func (s AuditService) ListForControlID(
return page.NewPage(audits, cursor), nil 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
}

View File

@@ -1,298 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,476 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -1,323 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -66,9 +66,8 @@ var ViewerPolicy = policy.NewPolicy(
ActionDatumGet, ActionDatumList, ActionDatumGet, ActionDatumList,
ActionAuditGet, ActionAuditList, ActionAuditGet, ActionAuditList,
ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet, ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet,
ActionNonconformityGet, ActionNonconformityList, ActionFindingGet, ActionFindingList,
ActionObligationGet, ActionObligationList, ActionObligationGet, ActionObligationList,
ActionContinualImprovementGet, ActionContinualImprovementList,
ActionProcessingActivityGet, ActionProcessingActivityList, ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList, ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList, ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,
@@ -134,9 +133,8 @@ var AuditorPolicy = policy.NewPolicy(
ActionDatumGet, ActionDatumList, ActionDatumGet, ActionDatumList,
ActionAuditGet, ActionAuditList, ActionAuditGet, ActionAuditList,
ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet, ActionReportGet, ActionReportGetReportUrl, ActionReportDownloadUrlGet,
ActionNonconformityGet, ActionNonconformityList, ActionFindingGet, ActionFindingList,
ActionObligationGet, ActionObligationList, ActionObligationGet, ActionObligationList,
ActionContinualImprovementGet, ActionContinualImprovementList,
ActionProcessingActivityGet, ActionProcessingActivityList, ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentGet,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList, ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -107,10 +107,9 @@ type (
TrustCenterFiles *TrustCenterFileService TrustCenterFiles *TrustCenterFileService
ComplianceFrameworks *ComplianceFrameworkService ComplianceFrameworks *ComplianceFrameworkService
ComplianceExternalURLs *ComplianceExternalURLService ComplianceExternalURLs *ComplianceExternalURLService
Nonconformities *NonconformityService Findings *FindingService
Obligations *ObligationService Obligations *ObligationService
Snapshots *SnapshotService Snapshots *SnapshotService
ContinualImprovements *ContinualImprovementService
RightsRequests *RightsRequestService RightsRequests *RightsRequestService
ProcessingActivities *ProcessingActivityService ProcessingActivities *ProcessingActivityService
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
@@ -257,10 +256,9 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
filevalidation.WithMaxFileSize(10*1024*1024), // 10MB filevalidation.WithMaxFileSize(10*1024*1024), // 10MB
), ),
} }
tenantService.Nonconformities = &NonconformityService{svc: tenantService} tenantService.Findings = &FindingService{svc: tenantService}
tenantService.Obligations = &ObligationService{svc: tenantService} tenantService.Obligations = &ObligationService{svc: tenantService}
tenantService.Snapshots = &SnapshotService{svc: tenantService} tenantService.Snapshots = &SnapshotService{svc: tenantService}
tenantService.ContinualImprovements = &ContinualImprovementService{svc: tenantService}
tenantService.RightsRequests = &RightsRequestService{svc: tenantService} tenantService.RightsRequests = &RightsRequestService{svc: tenantService}
tenantService.ProcessingActivities = &ProcessingActivityService{ tenantService.ProcessingActivities = &ProcessingActivityService{
svc: tenantService, svc: tenantService,