diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx index 446380be4..5a3edadb7 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { ClipboardTextIcon } from "@phosphor-icons/react"; import { formatError, type GraphQLError } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { @@ -251,6 +252,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi {__("Cookies")} + + + {__("Consent Records")} + diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPage.tsx new file mode 100644 index 000000000..127da970a --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPage.tsx @@ -0,0 +1,230 @@ +// 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. + +import { useTranslate } from "@probo/i18n"; +import { + Card, + Input, + Option, + Select, + Tbody, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { type ComponentProps, useState, useTransition } from "react"; +import { + graphql, + type PreloadedQuery, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; + +import type { CookieBannerConsentRecordsPageFragment$key } from "#/__generated__/core/CookieBannerConsentRecordsPageFragment.graphql"; +import type { CookieBannerConsentRecordsPageQuery } from "#/__generated__/core/CookieBannerConsentRecordsPageQuery.graphql"; +import type { + CookieBannerConsentRecordsPageRefetchQuery, + CookieConsentAction, + CookieConsentRecordOrderField, +} from "#/__generated__/core/CookieBannerConsentRecordsPageRefetchQuery.graphql"; +import { SortableTable, SortableTh } from "#/components/SortableTable"; + +import { ConsentRecordRow } from "./_components/ConsentRecordRow"; + +export const cookieBannerConsentRecordsPageQuery = graphql` + query CookieBannerConsentRecordsPageQuery($cookieBannerId: ID!) { + node(id: $cookieBannerId) @required(action: THROW) { + __typename + ... on CookieBanner { + ...CookieBannerConsentRecordsPageFragment + } + } + } +`; + +const consentRecordsFragment = graphql` + fragment CookieBannerConsentRecordsPageFragment on CookieBanner + @refetchable(queryName: "CookieBannerConsentRecordsPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { type: "CookieConsentRecordOrder", defaultValue: null } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + action: { type: "CookieConsentAction", defaultValue: null } + visitorId: { type: "String", defaultValue: null } + cookieBannerVersionId: { type: "ID", defaultValue: null } + ) { + consentRecords( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + filter: { + action: $action + visitorId: $visitorId + cookieBannerVersionId: $cookieBannerVersionId + } + ) + @connection( + key: "CookieBannerConsentRecordsPage_consentRecords" + filters: ["filter"] + ) @required(action: THROW) { + edges { + node { + id + ...ConsentRecordRowFragment + } + } + } + } +`; + +interface CookieBannerConsentRecordsPageProps { + queryRef: PreloadedQuery; +} + +export default function CookieBannerConsentRecordsPage({ + queryRef, +}: CookieBannerConsentRecordsPageProps) { + const { __ } = useTranslate(); + const data = usePreloadedQuery(cookieBannerConsentRecordsPageQuery, queryRef); + + if (data.node.__typename !== "CookieBanner") { + throw new Error("invalid type for node"); + } + + const [isPending, startTransition] = useTransition(); + const [actionFilter, setActionFilter] = useState(null); + const [visitorIdFilter, setVisitorIdFilter] = useState(""); + const [versionIdFilter, setVersionIdFilter] = useState(""); + + const { data: fragmentData, ...pagination } = usePaginationFragment< + CookieBannerConsentRecordsPageRefetchQuery, + CookieBannerConsentRecordsPageFragment$key + >(consentRecordsFragment, data.node); + + const records = fragmentData.consentRecords.edges.map(edge => edge.node) ?? []; + + const refetchFilters = (overrides: Record = {}) => { + startTransition(() => { + pagination.refetch( + { + action: actionFilter, + visitorId: visitorIdFilter || null, + cookieBannerVersionId: versionIdFilter || null, + ...overrides, + }, + { fetchPolicy: "network-only" }, + ); + }); + }; + + const handleActionFilterChange = (value: string) => { + const newAction = value === "ALL" ? null : (value as CookieConsentAction); + setActionFilter(newAction); + refetchFilters({ action: newAction }); + }; + + const handleVisitorIdSubmit = () => { + refetchFilters({ visitorId: visitorIdFilter || null }); + }; + + const handleVersionIdSubmit = () => { + refetchFilters({ cookieBannerVersionId: versionIdFilter || null }); + }; + + const refetchWithFilters: ComponentProps["refetch"] = ({ order }) => { + pagination.refetch({ + order: { direction: order.direction, field: order.field as CookieConsentRecordOrderField }, + action: actionFilter, + visitorId: visitorIdFilter || null, + cookieBannerVersionId: versionIdFilter || null, + }); + }; + + return ( +
+
+ + setVisitorIdFilter(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleVisitorIdSubmit()} + onBlur={handleVisitorIdSubmit} + className="w-48" + /> + setVersionIdFilter(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleVersionIdSubmit()} + onBlur={handleVersionIdSubmit} + className="w-48" + /> +
+ +
+ {records.length > 0 + ? ( + + + + {__("Visitor ID")} + {__("Action")} + {__("Version")} + {__("IP Address")} + {__("SDK Version")} + {__("Consent Data")} + {__("Date")} + + + + {records.map(record => ( + + ))} + + + ) + : ( + +
+

+ {__("No consent records")} +

+

+ {__("Consent records will appear here once visitors interact with your cookie banner.")} +

+
+
+ )} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader.tsx new file mode 100644 index 000000000..3d93ba6bb --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader.tsx @@ -0,0 +1,47 @@ +// 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. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { CookieBannerConsentRecordsPageQuery } from "#/__generated__/core/CookieBannerConsentRecordsPageQuery.graphql"; + +import CookieBannerConsentRecordsPage, { cookieBannerConsentRecordsPageQuery } from "./CookieBannerConsentRecordsPage"; +import { CookieBannerConsentRecordsPageSkeleton } from "./CookieBannerConsentRecordsPageSkeleton"; + +export default function CookieBannerConsentRecordsPageLoader() { + const { cookieBannerId } = useParams<{ cookieBannerId: string }>(); + if (typeof cookieBannerId !== "string") { + throw new Error("Missing cookieBannerId parameter"); + } + + const [queryRef, loadQuery] = useQueryLoader( + cookieBannerConsentRecordsPageQuery, + ); + + useEffect(() => { + loadQuery({ cookieBannerId }); + }, [loadQuery, cookieBannerId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageSkeleton.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageSkeleton.tsx new file mode 100644 index 000000000..5e8929a3b --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageSkeleton.tsx @@ -0,0 +1,31 @@ +// 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. + +export function CookieBannerConsentRecordsPageSkeleton() { + return ( +
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/_components/ConsentRecordRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/_components/ConsentRecordRow.tsx new file mode 100644 index 000000000..118758246 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/consent-records/_components/ConsentRecordRow.tsx @@ -0,0 +1,113 @@ +// 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. + +import { formatDate } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { Badge, Td, Tr } from "@probo/ui"; +import { graphql, useFragment } from "react-relay"; + +import type { ConsentRecordRowFragment$key } from "#/__generated__/core/ConsentRecordRowFragment.graphql"; + +const consentRecordFragment = graphql` + fragment ConsentRecordRowFragment on CookieConsentRecord { + visitorId + action + cookieBannerVersion { + id + version + } + ipAddress + sdkVersion + consentData + createdAt + } +`; + +function getActionLabel(action: string, __: (s: string) => string): string { + switch (action) { + case "ACCEPT_ALL": + return __("Accept All"); + case "REJECT_ALL": + return __("Reject All"); + case "CUSTOMIZE": + return __("Customize"); + case "GPC": + return __("GPC"); + default: + return action; + } +} + +function getActionVariant(action: string): "success" | "danger" | "warning" | "neutral" { + switch (action) { + case "ACCEPT_ALL": + return "success"; + case "REJECT_ALL": + return "danger"; + case "CUSTOMIZE": + return "warning"; + case "GPC": + return "neutral"; + default: + return "neutral"; + } +} + +interface ConsentRecordRowProps { + recordKey: ConsentRecordRowFragment$key; +} + +export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) { + const { __ } = useTranslate(); + const record = useFragment(consentRecordFragment, recordKey); + + return ( + + + {record.visitorId} + + + + {getActionLabel(record.action, __)} + + + + {record.cookieBannerVersion + ? ( + + v + {record.cookieBannerVersion.version} + + ) + : -} + + + {record.ipAddress ?? "-"} + + + {record.sdkVersion} + + + + {record.consentData} + + + + + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/routes.ts b/apps/console/src/pages/organizations/cookie-banners/routes.ts index a3987da3a..6fc0346ab 100644 --- a/apps/console/src/pages/organizations/cookie-banners/routes.ts +++ b/apps/console/src/pages/organizations/cookie-banners/routes.ts @@ -64,6 +64,11 @@ export const cookieBannerRoutes = [ Fallback: LinkCardSkeleton, Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPageLoader")), }, + { + path: "consent-records", + Fallback: LinkCardSkeleton, + Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader")), + }, ], }, ] satisfies AppRoute[]; diff --git a/contrib/claude/graphql.md b/contrib/claude/graphql.md index fb56e9f72..9ac33287a 100644 --- a/contrib/claude/graphql.md +++ b/contrib/claude/graphql.md @@ -7,10 +7,14 @@ Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-wri Each API's schema lives in `pkg/server/api/{api}/v1/graphql/` as multiple `.graphql` files, one per coredata model: - `base.graphql` — directives, scalars, Node interface, PageInfo, OrderDirection, root Query/Mutation/Organization types -- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Organization`, `extend type Mutation`, etc. +- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations. gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `vendor.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`. +### `extend type` restrictions + +**The only permitted use of `extend type` is `extend type Mutation`.** Never use `extend type` on any other type — not on entity types, not on `Organization`, not on `Query`. If `CookieBanner` needs a `consentRecords` connection, add the field directly to the `CookieBanner` type definition in `cookie_banner.graphql` — do not write `extend type CookieBanner` in another file. This keeps each entity's full field set visible in one place and avoids resolver mis-routing across generated files. + ## Connection types and `@goModel` **Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`. diff --git a/pkg/coredata/cookie_consent_record_filter.go b/pkg/coredata/cookie_consent_record_filter.go index 35f8ab3a6..6ae22c1fd 100644 --- a/pkg/coredata/cookie_consent_record_filter.go +++ b/pkg/coredata/cookie_consent_record_filter.go @@ -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 } diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 71ab9744a..4a6a1fc84 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -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" ) diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index e00d8cc96..0ae999ef0 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -88,6 +88,7 @@ var ViewerPolicy = policy.NewPolicy( ActionCookieBannerVersionGet, ActionCookieBannerVersionList, ActionCookieCategoryGet, ActionCookieCategoryList, ActionCookieGet, ActionCookieList, + ActionCookieConsentRecordList, ).WithSID("entity-read-access").When(organizationCondition), policy.Allow( diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index ce9ab48b4..14ceb2dd3 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -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) diff --git a/pkg/server/api/console/v1/cookie_consent_record_resolvers.go b/pkg/server/api/console/v1/cookie_consent_record_resolvers.go new file mode 100644 index 000000000..0b56b9863 --- /dev/null +++ b/pkg/server/api/console/v1/cookie_consent_record_resolvers.go @@ -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 } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 66505f7de..32d10c7d9 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -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! diff --git a/pkg/server/api/console/v1/graphql/cookie_consent_record.graphql b/pkg/server/api/console/v1/graphql/cookie_consent_record.graphql new file mode 100644 index 000000000..34264b37f --- /dev/null +++ b/pkg/server/api/console/v1/graphql/cookie_consent_record.graphql @@ -0,0 +1,84 @@ +# 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. + +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! +} diff --git a/pkg/server/api/console/v1/types/cookie_consent_record.go b/pkg/server/api/console/v1/types/cookie_consent_record.go new file mode 100644 index 000000000..09b1e01e5 --- /dev/null +++ b/pkg/server/api/console/v1/types/cookie_consent_record.go @@ -0,0 +1,86 @@ +// 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 ( + 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, + } +}