Add consent records tab to cookie banner config

Exposes the cookie consent record audit trail through a new
"Consent Records" tab on the cookie banner configuration page.
The full stack includes: extended coredata filter (visitor ID,
banner version), GraphQL schema/types/resolvers, and a React
page with SortableTable (size 50) and three compliance filters
(action, visitor ID, banner version).

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-27 13:24:14 +04:00
parent 86ffb8fbd6
commit 4147239fbc
15 changed files with 765 additions and 7 deletions

View File

@@ -14,15 +14,27 @@
package coredata
import "github.com/jackc/pgx/v5"
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type CookieConsentRecordFilter struct {
action *CookieConsentAction
action *CookieConsentAction
visitorID *string
cookieBannerVersionID *gid.GID
}
func NewCookieConsentRecordFilter(action *CookieConsentAction) *CookieConsentRecordFilter {
func NewCookieConsentRecordFilter(
action *CookieConsentAction,
visitorID *string,
cookieBannerVersionID *gid.GID,
) *CookieConsentRecordFilter {
return &CookieConsentRecordFilter{
action: action,
action: action,
visitorID: visitorID,
cookieBannerVersionID: cookieBannerVersionID,
}
}
@@ -31,7 +43,23 @@ func (f *CookieConsentRecordFilter) SQLFragment() string {
(
CASE
WHEN @filter_action::text IS NOT NULL THEN
action = @filter_action::consent_action
action = @filter_action::cookie_consent_action
ELSE TRUE
END
)
AND
(
CASE
WHEN @filter_visitor_id::text IS NOT NULL THEN
visitor_id = @filter_visitor_id
ELSE TRUE
END
)
AND
(
CASE
WHEN @filter_cookie_banner_version_id::text IS NOT NULL THEN
cookie_banner_version_id = @filter_cookie_banner_version_id
ELSE TRUE
END
)`
@@ -39,12 +67,22 @@ func (f *CookieConsentRecordFilter) SQLFragment() string {
func (f *CookieConsentRecordFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
"filter_action": nil,
"filter_action": nil,
"filter_visitor_id": nil,
"filter_cookie_banner_version_id": nil,
}
if f.action != nil {
args["filter_action"] = string(*f.action)
}
if f.visitorID != nil {
args["filter_visitor_id"] = *f.visitorID
}
if f.cookieBannerVersionID != nil {
args["filter_cookie_banner_version_id"] = f.cookieBannerVersionID.String()
}
return args
}

View File

@@ -408,4 +408,7 @@ const (
ActionCookieCreate = "core:cookie:create"
ActionCookieUpdate = "core:cookie:update"
ActionCookieDelete = "core:cookie:delete"
// CookieConsentRecord actions
ActionCookieConsentRecordList = "core:cookie-consent-record:list"
)

View File

@@ -88,6 +88,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionCookieBannerVersionGet, ActionCookieBannerVersionList,
ActionCookieCategoryGet, ActionCookieCategoryList,
ActionCookieGet, ActionCookieList,
ActionCookieConsentRecordList,
).WithSID("entity-read-access").When(organizationCondition),
policy.Allow(

View File

@@ -13,6 +13,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
@@ -122,6 +123,50 @@ func (r *cookieBannerResolver) LatestVersion(ctx context.Context, obj *types.Coo
}, nil
}
// ConsentRecords is the resolver for the consentRecords field.
func (r *cookieBannerResolver) ConsentRecords(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieConsentRecordOrderBy, filter *types.CookieConsentRecordFilter) (*types.CookieConsentRecordConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionCookieConsentRecordList); err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.CookieConsentRecordOrderField]{
Field: coredata.CookieConsentRecordOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.CookieConsentRecordOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
scope := coredata.NewScopeFromObjectID(obj.ID)
var (
action *coredata.CookieConsentAction
visitorID *string
cookieBannerVersionID *gid.GID
)
if filter != nil {
action = filter.Action
visitorID = filter.VisitorID
cookieBannerVersionID = filter.CookieBannerVersionID
}
coredataFilter := coredata.NewCookieConsentRecordFilter(action, visitorID, cookieBannerVersionID)
records, err := r.cookieBanner.ListCookieConsentRecordsForBanner(ctx, scope, obj.ID, cursor, coredataFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list consent records", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
p := page.NewPage(records, cursor)
return types.NewCookieConsentRecordConnection(p, r, obj.ID, coredataFilter), 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)

View File

@@ -0,0 +1,57 @@
package console_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.87
import (
"context"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// 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
}
// CookieBannerVersion is the resolver for the cookieBannerVersion field.
func (r *cookieConsentRecordResolver) CookieBannerVersion(ctx context.Context, obj *types.CookieConsentRecord) (*types.CookieBannerVersion, error) {
return obj.CookieBannerVersion, nil
}
// TotalCount is the resolver for the totalCount field.
func (r *cookieConsentRecordConnectionResolver) TotalCount(ctx context.Context, obj *types.CookieConsentRecordConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionCookieConsentRecordList); err != nil {
return 0, err
}
scope := coredata.NewScopeFromObjectID(obj.ParentID)
count, err := r.cookieBanner.CountCookieConsentRecordsForBanner(ctx, scope, obj.ParentID, obj.Filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count consent records", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// CookieConsentRecord returns schema.CookieConsentRecordResolver implementation.
func (r *Resolver) CookieConsentRecord() schema.CookieConsentRecordResolver {
return &cookieConsentRecordResolver{r}
}
// CookieConsentRecordConnection returns schema.CookieConsentRecordConnectionResolver implementation.
func (r *Resolver) CookieConsentRecordConnection() schema.CookieConsentRecordConnectionResolver {
return &cookieConsentRecordConnectionResolver{r}
}
type cookieConsentRecordResolver struct{ *Resolver }
type cookieConsentRecordConnectionResolver struct{ *Resolver }

View File

@@ -102,6 +102,15 @@ type CookieBanner implements Node {
latestVersion: CookieBannerVersion @goField(forceResolver: true)
consentRecords(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: CookieConsentRecordOrder
filter: CookieConsentRecordFilter
): CookieConsentRecordConnection @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!

View File

@@ -0,0 +1,84 @@
# 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.
enum CookieConsentAction
@goModel(model: "go.probo.inc/probo/pkg/coredata.CookieConsentAction") {
ACCEPT_ALL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieConsentActionAcceptAll"
)
REJECT_ALL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieConsentActionRejectAll"
)
CUSTOMIZE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieConsentActionCustomize"
)
GPC
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieConsentActionGPC"
)
}
enum CookieConsentRecordOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.CookieConsentRecordOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieConsentRecordOrderFieldCreatedAt"
)
}
input CookieConsentRecordOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieConsentRecordOrderBy"
) {
direction: OrderDirection!
field: CookieConsentRecordOrderField!
}
input CookieConsentRecordFilter {
action: CookieConsentAction
visitorId: String
cookieBannerVersionId: ID
}
type CookieConsentRecord implements Node {
id: ID!
cookieBanner: CookieBanner @goField(forceResolver: true)
cookieBannerVersion: CookieBannerVersion @goField(forceResolver: true)
visitorId: String!
ipAddress: String
userAgent: String
consentData: String!
action: CookieConsentAction!
sdkVersion: String!
createdAt: Datetime!
}
type CookieConsentRecordConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieConsentRecordConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [CookieConsentRecordEdge!]!
pageInfo: PageInfo!
}
type CookieConsentRecordEdge {
cursor: CursorKey!
node: CookieConsentRecord!
}

View File

@@ -0,0 +1,86 @@
// 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 types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
CookieConsentRecordOrderBy OrderBy[coredata.CookieConsentRecordOrderField]
CookieConsentRecordConnection struct {
TotalCount int
Edges []*CookieConsentRecordEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
Filter *coredata.CookieConsentRecordFilter
}
)
func NewCookieConsentRecordConnection(
p *page.Page[*coredata.CookieConsentRecord, coredata.CookieConsentRecordOrderField],
parentType any,
parentID gid.GID,
filter *coredata.CookieConsentRecordFilter,
) *CookieConsentRecordConnection {
edges := make([]*CookieConsentRecordEdge, len(p.Data))
for i := range edges {
edges[i] = NewCookieConsentRecordEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &CookieConsentRecordConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
Filter: filter,
}
}
func NewCookieConsentRecordEdge(
r *coredata.CookieConsentRecord,
orderBy coredata.CookieConsentRecordOrderField,
) *CookieConsentRecordEdge {
return &CookieConsentRecordEdge{
Cursor: r.CursorKey(orderBy),
Node: NewCookieConsentRecord(r),
}
}
func NewCookieConsentRecord(r *coredata.CookieConsentRecord) *CookieConsentRecord {
return &CookieConsentRecord{
ID: r.ID,
CookieBanner: &CookieBanner{
ID: r.CookieBannerID,
},
CookieBannerVersion: &CookieBannerVersion{
ID: r.CookieBannerVersionID,
},
VisitorID: r.VisitorID,
IPAddress: r.IPAddress,
UserAgent: r.UserAgent,
ConsentData: string(r.ConsentData),
Action: r.Action,
SdkVersion: r.SdkVersion,
CreatedAt: r.CreatedAt,
}
}