diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index e002bf241..f75976954 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -410,6 +410,13 @@ const ( ActionTrackerPatternUpdate = "core:tracker-pattern:update" ActionTrackerPatternDelete = "core:tracker-pattern:delete" + // TrackerResource actions + ActionTrackerResourceGet = "core:tracker-resource:get" + ActionTrackerResourceList = "core:tracker-resource:list" + ActionTrackerResourceCreate = "core:tracker-resource:create" + ActionTrackerResourceUpdate = "core:tracker-resource:update" + ActionTrackerResourceDelete = "core:tracker-resource:delete" + // CookieConsentRecord actions ActionCookieConsentRecordList = "core:cookie-consent-record:list" ) diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index 5ce9cbd1d..3d8a00afb 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -213,6 +213,42 @@ func (r *cookieBannerResolver) UncategorisedTrackerPatterns(ctx context.Context, return types.NewTrackerPatternConnectionWithFilter(p, r, obj.ID, filter), nil } +// UncategorisedTrackerResources is the resolver for the uncategorisedTrackerResources field. +func (r *cookieBannerResolver) UncategorisedTrackerResources(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TrackerResourceOrderBy, filter *types.TrackerResourceFilter) (*types.TrackerResourceConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionTrackerResourceList); err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.TrackerResourceOrderField]{ + Field: coredata.TrackerResourceOrderFieldLastDetectedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.TrackerResourceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + scope := coredata.NewScopeFromObjectID(obj.ID) + + coredataFilter := coredata.NewTrackerResourceFilter(nil, nil) + if filter != nil { + coredataFilter = coredataFilter.WithQuery(filter.Query).WithResourceType(filter.Type) + } + + resources, err := r.cookieBanner.ListUncategorisedTrackerResources(ctx, scope, obj.ID, cursor, coredataFilter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list uncategorised tracker resources", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + p := page.NewPage(resources, cursor) + + return types.NewTrackerResourceConnectionWithFilter(p, r, obj.ID, filter), nil +} + // Permission is the resolver for the permission field. func (r *cookieBannerResolver) Permission(ctx context.Context, obj *types.CookieBanner, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -333,6 +369,37 @@ func (r *cookieCategoryResolver) TrackerPatterns(ctx context.Context, obj *types return types.NewTrackerPatternConnection(p, r, obj.ID), nil } +// TrackerResources is the resolver for the trackerResources field. +func (r *cookieCategoryResolver) TrackerResources(ctx context.Context, obj *types.CookieCategory, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TrackerResourceOrderBy) (*types.TrackerResourceConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionTrackerResourceList); err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.TrackerResourceOrderField]{ + Field: coredata.TrackerResourceOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.TrackerResourceOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + scope := coredata.NewScopeFromObjectID(obj.ID) + + resources, err := r.cookieBanner.ListTrackerResourcesForCategory(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list tracker resources", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + p := page.NewPage(resources, cursor) + + return types.NewTrackerResourceConnection(p, r, obj.ID), nil +} + // Permission is the resolver for the permission field. func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -929,6 +996,177 @@ func (r *mutationResolver) MoveTrackerPatternToCategory(ctx context.Context, inp }, nil } +// CreateTrackerResource is the resolver for the createTrackerResource field. +func (r *mutationResolver) CreateTrackerResource(ctx context.Context, input types.CreateTrackerResourceInput) (*types.CreateTrackerResourcePayload, error) { + if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionTrackerResourceCreate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookieCategoryID) + + var description string + if input.Description != nil { + description = *input.Description + } + + resource, err := r.cookieBanner.CreateTrackerResource( + ctx, + scope, + cookiebanner.CreateTrackerResourceRequest{ + CookieCategoryID: input.CookieCategoryID, + ResourceType: input.Type, + Origin: input.Origin, + Path: input.Path, + DisplayName: input.DisplayName, + Description: description, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrResourceAlreadyExists) { + return nil, gqlutils.Conflictf(ctx, "a resource with this origin and path already exists in this banner") + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create tracker resource", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(resource.CookieBannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, resource.CookieBannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateTrackerResourcePayload{ + TrackerResourceEdge: types.NewTrackerResourceEdge(resource, coredata.TrackerResourceOrderFieldCreatedAt), + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// UpdateTrackerResource is the resolver for the updateTrackerResource field. +func (r *mutationResolver) UpdateTrackerResource(ctx context.Context, input types.UpdateTrackerResourceInput) (*types.UpdateTrackerResourcePayload, error) { + if err := r.authorize(ctx, input.TrackerResourceID, probo.ActionTrackerResourceUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerResourceID) + + resource, err := r.cookieBanner.UpdateTrackerResource( + ctx, + scope, + cookiebanner.UpdateTrackerResourceRequest{ + TrackerResourceID: input.TrackerResourceID, + DisplayName: input.DisplayName, + Description: input.Description, + Excluded: input.Excluded, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrTrackerResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update tracker resource", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(resource.CookieBannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, resource.CookieBannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateTrackerResourcePayload{ + TrackerResource: types.NewTrackerResourceNode(resource), + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// DeleteTrackerResource is the resolver for the deleteTrackerResource field. +func (r *mutationResolver) DeleteTrackerResource(ctx context.Context, input types.DeleteTrackerResourceInput) (*types.DeleteTrackerResourcePayload, error) { + if err := r.authorize(ctx, input.TrackerResourceID, probo.ActionTrackerResourceDelete); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerResourceID) + + resource, err := r.cookieBanner.GetTrackerResource(ctx, scope, input.TrackerResourceID) + if err != nil { + if errors.Is(err, cookiebanner.ErrTrackerResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot get tracker resource", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerID := resource.CookieBannerID + + if err := r.cookieBanner.DeleteTrackerResource(ctx, scope, input.TrackerResourceID); err != nil { + if errors.Is(err, cookiebanner.ErrTrackerResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete tracker resource", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(bannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, bannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteTrackerResourcePayload{ + DeletedTrackerResourceID: input.TrackerResourceID, + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// MoveTrackerResourceToCategory is the resolver for the moveTrackerResourceToCategory field. +func (r *mutationResolver) MoveTrackerResourceToCategory(ctx context.Context, input types.MoveTrackerResourceToCategoryInput) (*types.MoveTrackerResourceToCategoryPayload, error) { + if err := r.authorize(ctx, input.TrackerResourceID, probo.ActionTrackerResourceUpdate); err != nil { + return nil, err + } + + if err := r.authorize(ctx, input.TargetCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerResourceID) + + result, err := r.cookieBanner.MoveTrackerResourceToCategory( + ctx, + scope, + cookiebanner.MoveTrackerResourceToCategoryRequest{ + TrackerResourceID: input.TrackerResourceID, + TargetCookieCategoryID: input.TargetCookieCategoryID, + }, + ) + if err != nil { + switch { + case errors.Is(err, cookiebanner.ErrCategoryNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrTrackerResourceNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch): + return nil, gqlutils.NotFoundf(ctx, "tracker resource or target category not found") + default: + r.logger.ErrorCtx(ctx, "cannot move tracker resource to category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + } + + return &types.MoveTrackerResourceToCategoryPayload{ + TrackerResource: types.NewTrackerResourceNode(result.TrackerResource), + CookieBanner: types.NewCookieBanner(result.Banner), + }, nil +} + // CookieCategory is the resolver for the cookieCategory field. func (r *trackerPatternResolver) CookieCategory(ctx context.Context, obj *types.TrackerPattern) (*types.CookieCategory, error) { if err := r.authorize(ctx, obj.CookieCategory.ID, probo.ActionCookieCategoryGet); err != nil { @@ -993,6 +1231,57 @@ func (r *trackerPatternConnectionResolver) TotalCount(ctx context.Context, obj * return count, nil } +// CookieCategory is the resolver for the cookieCategory field. +func (r *trackerResourceResolver) CookieCategory(ctx context.Context, obj *types.TrackerResource) (*types.CookieCategory, error) { + if err := r.authorize(ctx, obj.CookieCategory.ID, probo.ActionCookieCategoryGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + category, err := loaders.CookieCategory.Load(ctx, obj.CookieCategory.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot get cookie category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCookieCategory(category), nil +} + +// Permission is the resolver for the permission field. +func (r *trackerResourceResolver) Permission(ctx context.Context, obj *types.TrackerResource, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *trackerResourceConnectionResolver) TotalCount(ctx context.Context, obj *types.TrackerResourceConnection) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + var count int + var err error + + switch obj.Resolver.(type) { + case *cookieCategoryResolver: + count, err = r.cookieBanner.CountTrackerResourcesForCategory(ctx, scope, obj.ParentID) + default: + filter := coredata.NewTrackerResourceFilter(nil, nil) + if obj.Filter != nil { + filter = filter.WithQuery(obj.Filter.Query).WithResourceType(obj.Filter.Type) + } + count, err = r.cookieBanner.CountUncategorisedTrackerResources(ctx, scope, obj.ParentID, filter) + } + + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count tracker resources", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + // CookieBanner returns schema.CookieBannerResolver implementation. func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} } @@ -1022,6 +1311,16 @@ func (r *Resolver) TrackerPatternConnection() schema.TrackerPatternConnectionRes return &trackerPatternConnectionResolver{r} } +// TrackerResource returns schema.TrackerResourceResolver implementation. +func (r *Resolver) TrackerResource() schema.TrackerResourceResolver { + return &trackerResourceResolver{r} +} + +// TrackerResourceConnection returns schema.TrackerResourceConnectionResolver implementation. +func (r *Resolver) TrackerResourceConnection() schema.TrackerResourceConnectionResolver { + return &trackerResourceConnectionResolver{r} +} + type cookieBannerResolver struct{ *Resolver } type cookieBannerConnectionResolver struct{ *Resolver } type cookieBannerVersionResolver struct{ *Resolver } @@ -1029,3 +1328,5 @@ type cookieCategoryResolver struct{ *Resolver } type cookieCategoryConnectionResolver struct{ *Resolver } type trackerPatternResolver struct{ *Resolver } type trackerPatternConnectionResolver struct{ *Resolver } +type trackerResourceResolver struct{ *Resolver } +type trackerResourceConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 941c75400..d241ead76 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -152,6 +152,15 @@ type CookieBanner implements Node { filter: TrackerPatternFilter ): TrackerPatternConnection @goField(forceResolver: true) + uncategorisedTrackerResources( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: TrackerResourceOrder + filter: TrackerResourceFilter + ): TrackerResourceConnection @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -185,6 +194,14 @@ type CookieCategory implements Node { orderBy: TrackerPatternOrder ): TrackerPatternConnection @goField(forceResolver: true) + trackerResources( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: TrackerResourceOrder + ): TrackerResourceConnection @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -281,6 +298,86 @@ input TrackerPatternFilter trackerType: TrackerType } +enum TrackerResourceType + @goModel(model: "go.probo.inc/probo/pkg/coredata.TrackerResourceType") { + SCRIPT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeScript" + ) + IFRAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceTypeIframe" + ) +} + +enum TrackerResourceOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.TrackerResourceOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceOrderFieldCreatedAt" + ) + LAST_DETECTED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceOrderFieldLastDetectedAt" + ) + ORIGIN + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceOrderFieldOrigin" + ) + UPDATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TrackerResourceOrderFieldUpdatedAt" + ) +} + +type TrackerResource implements Node { + id: ID! + cookieCategory: CookieCategory @goField(forceResolver: true) + type: TrackerResourceType! + origin: String! + path: String! + displayName: String! + description: String! + excluded: Boolean! + lastDetectedAt: Datetime + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type TrackerResourceConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerResourceConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [TrackerResourceEdge!]! + pageInfo: PageInfo! +} + +type TrackerResourceEdge { + cursor: CursorKey! + node: TrackerResource! +} + +input TrackerResourceOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerResourceOrderBy" + ) { + direction: OrderDirection! + field: TrackerResourceOrderField! +} + +input TrackerResourceFilter + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerResourceFilter" + ) { + query: String + type: TrackerResourceType +} + type CookieBannerVersion implements Node { id: ID! version: Int! @@ -378,6 +475,18 @@ extend type Mutation { moveTrackerPatternToCategory( input: MoveTrackerPatternToCategoryInput! ): MoveTrackerPatternToCategoryPayload! + createTrackerResource( + input: CreateTrackerResourceInput! + ): CreateTrackerResourcePayload! + updateTrackerResource( + input: UpdateTrackerResourceInput! + ): UpdateTrackerResourcePayload! + deleteTrackerResource( + input: DeleteTrackerResourceInput! + ): DeleteTrackerResourcePayload! + moveTrackerResourceToCategory( + input: MoveTrackerResourceToCategoryInput! + ): MoveTrackerResourceToCategoryPayload! } input CreateCookieBannerInput { @@ -542,3 +651,48 @@ type MoveTrackerPatternToCategoryPayload { trackerPattern: TrackerPattern! cookieBanner: CookieBanner! } + +input CreateTrackerResourceInput { + cookieCategoryId: ID! + type: TrackerResourceType! + origin: String! + path: String! + displayName: String! + description: String +} + +input UpdateTrackerResourceInput { + trackerResourceId: ID! + displayName: String + description: String + excluded: Boolean +} + +input DeleteTrackerResourceInput { + trackerResourceId: ID! +} + +input MoveTrackerResourceToCategoryInput { + trackerResourceId: ID! + targetCookieCategoryId: ID! +} + +type CreateTrackerResourcePayload { + trackerResourceEdge: TrackerResourceEdge! + cookieBanner: CookieBanner! +} + +type UpdateTrackerResourcePayload { + trackerResource: TrackerResource! + cookieBanner: CookieBanner! +} + +type DeleteTrackerResourcePayload { + deletedTrackerResourceId: ID! + cookieBanner: CookieBanner! +} + +type MoveTrackerResourceToCategoryPayload { + trackerResource: TrackerResource! + cookieBanner: CookieBanner! +} diff --git a/pkg/server/api/console/v1/types/tracker_resource.go b/pkg/server/api/console/v1/types/tracker_resource.go new file mode 100644 index 000000000..78ccb249e --- /dev/null +++ b/pkg/server/api/console/v1/types/tracker_resource.go @@ -0,0 +1,99 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + TrackerResourceOrderBy OrderBy[coredata.TrackerResourceOrderField] + + TrackerResourceConnection struct { + TotalCount int + Edges []*TrackerResourceEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + Filter *TrackerResourceFilter + } + + TrackerResourceFilter struct { + Query *string + Type *coredata.TrackerResourceType + } +) + +func NewTrackerResourceConnection( + p *page.Page[*coredata.TrackerResource, coredata.TrackerResourceOrderField], + parentType any, + parentID gid.GID, +) *TrackerResourceConnection { + edges := make([]*TrackerResourceEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewTrackerResourceEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &TrackerResourceConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, + } +} + +func NewTrackerResourceConnectionWithFilter( + p *page.Page[*coredata.TrackerResource, coredata.TrackerResourceOrderField], + parentType any, + parentID gid.GID, + filter *TrackerResourceFilter, +) *TrackerResourceConnection { + conn := NewTrackerResourceConnection(p, parentType, parentID) + conn.Filter = filter + return conn +} + +func NewTrackerResourceEdge(tr *coredata.TrackerResource, orderBy coredata.TrackerResourceOrderField) *TrackerResourceEdge { + return &TrackerResourceEdge{ + Cursor: tr.CursorKey(orderBy), + Node: NewTrackerResourceNode(tr), + } +} + +func NewTrackerResourceNode(tr *coredata.TrackerResource) *TrackerResource { + return &TrackerResource{ + ID: tr.ID, + CookieCategory: &CookieCategory{ + ID: tr.CookieCategoryID, + CookieBanner: &CookieBanner{ + ID: tr.CookieBannerID, + }, + }, + Type: tr.ResourceType, + Origin: tr.Origin, + Path: tr.Path, + DisplayName: tr.DisplayName, + Description: tr.Description, + Excluded: tr.Excluded, + LastDetectedAt: tr.LastDetectedAt, + CreatedAt: tr.CreatedAt, + UpdatedAt: tr.UpdatedAt, + } +}