Add consent record detail page

Display record attributes and parsed consent data with
per-category consent state and cookies from the banner
version snapshot. The page lives outside the config layout
with its own breadcrumb navigation.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-27 16:01:16 +04:00
parent ff2c7b9cce
commit 9b83e319a0
13 changed files with 588 additions and 41 deletions

View File

@@ -1738,6 +1738,34 @@ func (s *Service) ListCookieConsentRecordsForBanner(
return records, nil
}
func (s *Service) GetCookieConsentRecord(
ctx context.Context,
scope coredata.Scoper,
id gid.GID,
) (*coredata.CookieConsentRecord, error) {
var record coredata.CookieConsentRecord
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := record.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrConsentNotFound
}
return fmt.Errorf("cannot load consent record: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &record, nil
}
func (s *Service) CountCookieConsentRecordsForBanner(
ctx context.Context,
scope coredata.Scoper,

View File

@@ -56,7 +56,18 @@ func (r *CookieConsentRecord) CursorKey(field CookieConsentRecordOrderField) pag
}
func (r *CookieConsentRecord) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
return map[string]string{"organization_id": r.OrganizationID.String()}, nil
q := `SELECT organization_id FROM cookie_consent_records WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, r.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query consent record authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (r *CookieConsentRecords) LoadByCookieBannerID(
@@ -203,6 +214,56 @@ INSERT INTO cookie_consent_records (
return nil
}
func (r *CookieConsentRecord) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_banner_version_id,
visitor_id,
ip_address,
user_agent,
consent_data,
action,
sdk_version,
created_at
FROM
cookie_consent_records
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": id}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query consent record: %w", err)
}
record, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieConsentRecord])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect consent record: %w", err)
}
*r = record
return nil
}
func (r *CookieConsentRecord) LoadLatestByVisitorAndBannerID(
ctx context.Context,
conn pg.Querier,

View File

@@ -352,6 +352,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewCookieCategory(category), nil
}
case coredata.CookieConsentRecordEntityType:
action = probo.ActionCookieConsentRecordList
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
scope := coredata.NewScopeFromObjectID(id)
record, err := r.cookieBanner.GetCookieConsentRecord(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewCookieConsentRecord(record), nil
}
case coredata.CookieBannerVersionEntityType:
action = probo.ActionCookieBannerVersionGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {

View File

@@ -188,6 +188,45 @@ func (r *cookieBannerConnectionResolver) TotalCount(ctx context.Context, obj *ty
return count, nil
}
// Categories is the resolver for the categories field.
func (r *cookieBannerVersionResolver) Categories(ctx context.Context, obj *types.CookieBannerVersion) ([]*types.CookieBannerVersionCategory, error) {
scope := coredata.NewScopeFromObjectID(obj.ID)
version, err := r.cookieBanner.GetCookieBannerVersion(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get cookie banner version", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
snapshot, err := version.GetSnapshot()
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get version snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
categories := make([]*types.CookieBannerVersionCategory, len(snapshot.Categories))
for i, cat := range snapshot.Categories {
cookies := make([]*types.CookieBannerVersionCookie, len(cat.Cookies))
for j, c := range cat.Cookies {
cookies[j] = &types.CookieBannerVersionCookie{
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
}
}
categories[i] = &types.CookieBannerVersionCategory{
Name: cat.Name,
Slug: cat.Slug,
Description: cat.Description,
Kind: cat.Kind,
Cookies: cookies,
}
}
return categories, nil
}
// CookieBanner is the resolver for the cookieBanner field.
func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.CookieCategory) (*types.CookieBanner, error) {
return obj.CookieBanner, nil
@@ -848,6 +887,11 @@ func (r *Resolver) CookieBannerConnection() schema.CookieBannerConnectionResolve
return &cookieBannerConnectionResolver{r}
}
// CookieBannerVersion returns schema.CookieBannerVersionResolver implementation.
func (r *Resolver) CookieBannerVersion() schema.CookieBannerVersionResolver {
return &cookieBannerVersionResolver{r}
}
// CookieCategory returns schema.CookieCategoryResolver implementation.
func (r *Resolver) CookieCategory() schema.CookieCategoryResolver { return &cookieCategoryResolver{r} }
@@ -864,6 +908,7 @@ func (r *Resolver) CookieConnection() schema.CookieConnectionResolver {
type cookieResolver struct{ *Resolver }
type cookieBannerResolver struct{ *Resolver }
type cookieBannerConnectionResolver struct{ *Resolver }
type cookieBannerVersionResolver struct{ *Resolver }
type cookieCategoryResolver struct{ *Resolver }
type cookieCategoryConnectionResolver struct{ *Resolver }
type cookieConnectionResolver struct{ *Resolver }

View File

@@ -20,7 +20,26 @@ import (
// CookieBanner is the resolver for the cookieBanner field.
func (r *cookieConsentRecordResolver) CookieBanner(ctx context.Context, obj *types.CookieConsentRecord) (*types.CookieBanner, error) {
return obj.CookieBanner, nil
if obj.CookieBanner == nil {
return nil, nil
}
if err := r.authorize(ctx, obj.CookieBanner.ID, probo.ActionCookieBannerGet); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(obj.CookieBanner.ID)
banner, err := r.cookieBanner.GetCookieBanner(ctx, scope, obj.CookieBanner.ID)
if err != nil {
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewCookieBanner(banner), nil
}
// CookieBannerVersion is the resolver for the cookieBannerVersion field.

View File

@@ -198,10 +198,25 @@ type CookieBannerVersion implements Node {
id: ID!
version: Int!
state: String!
categories: [CookieBannerVersionCategory!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type CookieBannerVersionCategory {
name: String!
slug: String!
description: String!
kind: CookieCategoryKind!
cookies: [CookieBannerVersionCookie!]!
}
type CookieBannerVersionCookie {
name: String!
duration: String!
description: String!
}
type CookieBannerConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieBannerConnection"