Surface third party links on TrackerPattern in GraphQL

Each tracker pattern carries either a direct org-scoped third_party_id
or an indirect link via common_tracker_pattern_id, but the console API
never surfaced either. Expose two optional resolver-driven fields on
the GraphQL TrackerPattern node:

  thirdParty: ThirdParty
  commonThirdParty: CommonThirdParty

The org-scoped ThirdParty takes priority. When ThirdPartyID is set the
commonThirdParty resolver short-circuits to nil, so the chained
common_tracker_pattern -> common_third_party lookup is only paid for
when a pattern has not been promoted to a tenant-managed third party.

To make the resolver pattern viable across paginated banner trackers
listings, the model now uses @goModel and a custom struct that carries
the foreign-key handles (ThirdPartyID, CommonTrackerPatternID) without
exposing them in the schema. NewTrackerPatternNode populates them from
coredata.

Two new request-scoped dataloaders (CommonTrackerPattern,
CommonThirdParty) batch the chained lookup, mirroring the existing
ThirdParty / CookieCategory loaders. The console mux now wires the
third-party service through dataloader.NewMiddleware so the second
loader has its backing service.

Authorization follows existing precedent: ActionThirdPartyGet for the
org-scoped lookup, ActionCommonThirdPartyGet (granted by the
identity-scoped CommonThirdPartyCatalogPolicy) for the catalog lookup.
ErrResourceNotFound and dataloadgen.ErrNotFound are mapped to a null
field rather than an error.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-27 21:02:53 +02:00
parent cdd7eb1171
commit d93ff7ba25
5 changed files with 213 additions and 42 deletions

View File

@@ -16,6 +16,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
@@ -1273,6 +1274,78 @@ func (r *trackerPatternResolver) DetectedCount(ctx context.Context, obj *types.T
return count, nil
}
// ThirdParty is the resolver for the thirdParty field.
func (r *trackerPatternResolver) ThirdParty(ctx context.Context, obj *types.TrackerPattern) (*types.ThirdParty, error) {
if obj.ThirdPartyID == nil {
return nil, nil
}
if _, err := r.authorize(ctx, *obj.ThirdPartyID, probo.ActionThirdPartyGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
tp, err := loaders.ThirdParty.Load(ctx, *obj.ThirdPartyID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get tracker pattern third party", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdParty(tp), nil
}
// CommonThirdParty is the resolver for the commonThirdParty field.
//
// The org-scoped thirdParty takes priority: when ThirdPartyID is set we
// short-circuit to nil so the chained common-tracker-pattern lookup is
// never paid for.
func (r *trackerPatternResolver) CommonThirdParty(ctx context.Context, obj *types.TrackerPattern) (*types.CommonThirdParty, error) {
if obj.ThirdPartyID != nil || obj.CommonTrackerPatternID == nil {
return nil, nil
}
identity := authn.IdentityFromContext(ctx)
if _, err := r.authorize(ctx, identity.ID, probo.ActionCommonThirdPartyGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
pattern, err := loaders.CommonTrackerPattern.Load(ctx, *obj.CommonTrackerPatternID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get common tracker pattern", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if pattern.CommonThirdPartyID == nil {
return nil, nil
}
party, err := loaders.CommonThirdParty.Load(ctx, *pattern.CommonThirdPartyID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get common third party", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewCommonThirdParty(party), nil
}
// DetectedTrackers is the resolver for the detectedTrackers field.
func (r *trackerPatternResolver) DetectedTrackers(ctx context.Context, obj *types.TrackerPattern, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DetectedTrackerOrderBy) (*types.DetectedTrackerConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternGet)