cookiebanner: add TrackerResource service layer
Wire resource ingestion and add full CRUD + list/count service methods for the new tracker_resources table. - reportDetectedResource splits the URL into origin/path and upserts into tracker_resources with the uncategorised category. - CreateTrackerResource, GetTrackerResource, UpdateTrackerResource, DeleteTrackerResource, MoveTrackerResourceToCategory mirror the tracker-pattern service surface. - ListTrackerResourcesForCategory, CountTrackerResourcesForCategory, ListUncategorisedTrackerResources, CountUncategorisedTrackerResources provide paginated access. - Request structs with Validate() and dedicated error sentinels. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -35,4 +35,7 @@ var (
|
||||
ErrTrackerPatternNotFound = errors.New("tracker pattern not found")
|
||||
ErrPatternAlreadyExists = errors.New("a pattern with this name already exists in this banner")
|
||||
ErrSamePatternCategoryMove = errors.New("source and target cookie categories must be different")
|
||||
ErrTrackerResourceNotFound = errors.New("tracker resource not found")
|
||||
ErrResourceAlreadyExists = errors.New("a resource with this origin and path already exists in this banner")
|
||||
ErrSameResourceCategoryMove = errors.New("source and target cookie categories must be different")
|
||||
)
|
||||
|
||||
@@ -164,6 +164,32 @@ type (
|
||||
Banner *coredata.CookieBanner
|
||||
}
|
||||
|
||||
CreateTrackerResourceRequest struct {
|
||||
CookieCategoryID gid.GID
|
||||
ResourceType coredata.TrackerResourceType
|
||||
Origin string
|
||||
Path string
|
||||
DisplayName string
|
||||
Description string
|
||||
}
|
||||
|
||||
UpdateTrackerResourceRequest struct {
|
||||
TrackerResourceID gid.GID
|
||||
DisplayName *string
|
||||
Description *string
|
||||
Excluded *bool
|
||||
}
|
||||
|
||||
MoveTrackerResourceToCategoryRequest struct {
|
||||
TrackerResourceID gid.GID
|
||||
TargetCookieCategoryID gid.GID
|
||||
}
|
||||
|
||||
MoveTrackerResourceToCategoryResult struct {
|
||||
TrackerResource *coredata.TrackerResource
|
||||
Banner *coredata.CookieBanner
|
||||
}
|
||||
|
||||
BannerConfig struct {
|
||||
BannerID gid.GID `json:"banner_id"`
|
||||
Version int `json:"version"`
|
||||
@@ -383,6 +409,42 @@ func (r *UpdateTrackerPatternRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (r *CreateTrackerResourceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
|
||||
v.Check(string(r.ResourceType), "resource_type", validator.Required(), validator.OneOfSlice(
|
||||
func() []string {
|
||||
types := coredata.TrackerResourceTypes()
|
||||
s := make([]string, len(types))
|
||||
for i, t := range types {
|
||||
s[i] = string(t)
|
||||
}
|
||||
return s
|
||||
}(),
|
||||
))
|
||||
v.Check(r.Origin, "origin", validator.Required(), validator.Origin())
|
||||
v.Check(r.Path, "path", validator.Required(), validator.SafeTextNoNewLine(2048))
|
||||
v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255))
|
||||
v.Check(r.Description, "description", validator.SafeText(1000))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (r *UpdateTrackerResourceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(r.TrackerResourceID, "tracker_resource_id", validator.Required(), validator.GID(coredata.TrackerResourceEntityType))
|
||||
if r.DisplayName != nil {
|
||||
v.Check(*r.DisplayName, "display_name", validator.SafeTextNoNewLine(255))
|
||||
}
|
||||
if r.Description != nil {
|
||||
v.Check(*r.Description, "description", validator.SafeText(1000))
|
||||
}
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func CanonicalizeOrigin(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -1999,10 +2061,23 @@ func (s *Service) ReportDetectedTrackers(
|
||||
}
|
||||
}
|
||||
|
||||
// Resources (SCRIPT/IFRAME) are now stored in their own
|
||||
// tracker_resources table; ingestion will be wired in a
|
||||
// follow-up commit alongside the new service surface.
|
||||
_ = req.Resources
|
||||
for _, dr := range req.Resources {
|
||||
wasInserted, err := s.reportDetectedResource(
|
||||
ctx,
|
||||
tx,
|
||||
scope,
|
||||
&banner,
|
||||
uncategorised.ID,
|
||||
now,
|
||||
dr,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wasInserted {
|
||||
inserted++
|
||||
}
|
||||
}
|
||||
|
||||
if inserted > 0 {
|
||||
if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
|
||||
@@ -2105,6 +2180,49 @@ func (s *Service) reportDetectedTracker(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) reportDetectedResource(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
banner *coredata.CookieBanner,
|
||||
uncategorisedID gid.GID,
|
||||
now time.Time,
|
||||
item DetectedResourceItem,
|
||||
) (bool, error) {
|
||||
u, err := url.Parse(item.URL.String())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot parse resource URL: %w", err)
|
||||
}
|
||||
|
||||
origin := u.Scheme + "://" + u.Host
|
||||
path := u.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
resource := &coredata.TrackerResource{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.TrackerResourceEntityType),
|
||||
OrganizationID: banner.OrganizationID,
|
||||
CookieBannerID: banner.ID,
|
||||
CookieCategoryID: uncategorisedID,
|
||||
ResourceType: item.ResourceType,
|
||||
Origin: origin,
|
||||
Path: path,
|
||||
DisplayName: u.Host + path,
|
||||
Description: "",
|
||||
LastDetectedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
inserted, err := resource.Upsert(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot upsert tracker resource: %w", err)
|
||||
}
|
||||
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateTrackerPattern(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
@@ -2485,3 +2603,324 @@ func (s *Service) CountDetectedTrackersByPatternID(
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateTrackerResource(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req CreateTrackerResourceRequest,
|
||||
) (*coredata.TrackerResource, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
var resource *coredata.TrackerResource
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
var category coredata.CookieCategory
|
||||
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrCategoryNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load cookie category: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
resource = &coredata.TrackerResource{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.TrackerResourceEntityType),
|
||||
OrganizationID: category.OrganizationID,
|
||||
CookieBannerID: category.CookieBannerID,
|
||||
CookieCategoryID: category.ID,
|
||||
ResourceType: req.ResourceType,
|
||||
Origin: req.Origin,
|
||||
Path: req.Path,
|
||||
DisplayName: req.DisplayName,
|
||||
Description: req.Description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := resource.Insert(ctx, tx, scope); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("cannot insert tracker resource: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetTrackerResource(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
trackerResourceID gid.GID,
|
||||
) (*coredata.TrackerResource, error) {
|
||||
var resource coredata.TrackerResource
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
if err := resource.LoadByID(ctx, conn, scope, trackerResourceID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrTrackerResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load tracker resource: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTrackerResource(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req UpdateTrackerResourceRequest,
|
||||
) (*coredata.TrackerResource, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resource coredata.TrackerResource
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
if err := resource.LoadByID(ctx, tx, scope, req.TrackerResourceID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrTrackerResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load tracker resource: %w", err)
|
||||
}
|
||||
|
||||
displayNameChanged := req.DisplayName != nil && *req.DisplayName != resource.DisplayName
|
||||
descChanged := req.Description != nil && *req.Description != resource.Description
|
||||
excludedChanged := req.Excluded != nil && *req.Excluded != resource.Excluded
|
||||
|
||||
if !displayNameChanged && !descChanged && !excludedChanged {
|
||||
return nil
|
||||
}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
resource.DisplayName = *req.DisplayName
|
||||
}
|
||||
if req.Description != nil {
|
||||
resource.Description = *req.Description
|
||||
}
|
||||
if req.Excluded != nil {
|
||||
resource.Excluded = *req.Excluded
|
||||
}
|
||||
|
||||
resource.UpdatedAt = time.Now()
|
||||
|
||||
if err := resource.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update tracker resource: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteTrackerResource(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
trackerResourceID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
var resource coredata.TrackerResource
|
||||
if err := resource.LoadByID(ctx, tx, scope, trackerResourceID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrTrackerResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load tracker resource: %w", err)
|
||||
}
|
||||
|
||||
if err := resource.Delete(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot delete tracker resource: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) MoveTrackerResourceToCategory(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
req MoveTrackerResourceToCategoryRequest,
|
||||
) (*MoveTrackerResourceToCategoryResult, error) {
|
||||
var result MoveTrackerResourceToCategoryResult
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
var resource coredata.TrackerResource
|
||||
if err := resource.LoadByID(ctx, tx, scope, req.TrackerResourceID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrTrackerResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load tracker resource: %w", err)
|
||||
}
|
||||
|
||||
var target coredata.CookieCategory
|
||||
if err := target.LoadByID(ctx, tx, scope, req.TargetCookieCategoryID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return ErrCategoryNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot load target cookie category: %w", err)
|
||||
}
|
||||
|
||||
if resource.CookieCategoryID == target.ID {
|
||||
return ErrSameResourceCategoryMove
|
||||
}
|
||||
|
||||
if resource.CookieBannerID != target.CookieBannerID {
|
||||
return ErrCategoriesBannerMismatch
|
||||
}
|
||||
|
||||
resource.CookieCategoryID = target.ID
|
||||
resource.UpdatedAt = time.Now()
|
||||
|
||||
if err := resource.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update tracker resource: %w", err)
|
||||
}
|
||||
|
||||
var banner coredata.CookieBanner
|
||||
if err := banner.LoadByID(ctx, tx, scope, resource.CookieBannerID); err != nil {
|
||||
return fmt.Errorf("cannot load cookie banner: %w", err)
|
||||
}
|
||||
|
||||
result.TrackerResource = &resource
|
||||
result.Banner = &banner
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListTrackerResourcesForCategory(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
categoryID gid.GID,
|
||||
cursor *page.Cursor[coredata.TrackerResourceOrderField],
|
||||
) (coredata.TrackerResources, error) {
|
||||
var resources coredata.TrackerResources
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return resources.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot list tracker resources for category: %w", err)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (s *Service) CountTrackerResourcesForCategory(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
categoryID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
var resources coredata.TrackerResources
|
||||
var err error
|
||||
|
||||
count, err = resources.CountByCookieCategoryID(ctx, conn, scope, categoryID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count tracker resources for category: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListUncategorisedTrackerResources(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
bannerID gid.GID,
|
||||
cursor *page.Cursor[coredata.TrackerResourceOrderField],
|
||||
filter *coredata.TrackerResourceFilter,
|
||||
) (coredata.TrackerResources, error) {
|
||||
var resources coredata.TrackerResources
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
if err := resources.LoadUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil {
|
||||
return fmt.Errorf("cannot list uncategorised tracker resources: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func (s *Service) CountUncategorisedTrackerResources(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
bannerID gid.GID,
|
||||
filter *coredata.TrackerResourceFilter,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
var resources coredata.TrackerResources
|
||||
var err error
|
||||
|
||||
count, err = resources.CountUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count uncategorised tracker resources: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user