From f9fec45eb116ba24f8010e6ade0833643b459fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 29 Apr 2026 13:52:01 +0400 Subject: [PATCH] Add cookie pattern entity to group detected cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a cookie_patterns table that groups cookies sharing a common prefix (e.g. phc_*) into a single manageable row. Every cookie now belongs to a pattern (EXACT or PREFIX match type). Category, description, and display metadata move from cookies to patterns, making patterns the unit of management and display in the console and published snapshots. Signed-off-by: Émile Ré --- pkg/cookiebanner/service.go | 196 +++++-- pkg/coredata/cookie.go | 211 +++++--- pkg/coredata/cookie_pattern.go | 488 ++++++++++++++++++ pkg/coredata/cookie_pattern_match_type.go | 70 +++ pkg/coredata/cookie_pattern_order_field.go | 55 ++ pkg/coredata/entity_type_reg.go | 3 + pkg/coredata/migrations/20260429T084452Z.sql | 63 +++ .../api/console/v1/cookie_banner_resolvers.go | 15 +- pkg/server/api/console/v1/types/cookie.go | 22 +- 9 files changed, 980 insertions(+), 143 deletions(-) create mode 100644 pkg/coredata/cookie_pattern.go create mode 100644 pkg/coredata/cookie_pattern_match_type.go create mode 100644 pkg/coredata/cookie_pattern_order_field.go create mode 100644 pkg/coredata/migrations/20260429T084452Z.sql diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 1b79a08ad..42b0f63b9 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -335,17 +335,17 @@ func CanonicalizeOrigin(raw string) string { func buildSnapshot( banner *coredata.CookieBanner, categories coredata.CookieCategories, - allCookies coredata.Cookies, + allPatterns coredata.CookiePatterns, translations coredata.CookieBannerTranslations, ) coredata.CookieBannerVersionSnapshot { cookiesByCategory := make(map[gid.GID]coredata.CookieItems) - for _, c := range allCookies { - cookiesByCategory[c.CookieCategoryID] = append( - cookiesByCategory[c.CookieCategoryID], + for _, p := range allPatterns { + cookiesByCategory[p.CookieCategoryID] = append( + cookiesByCategory[p.CookieCategoryID], coredata.CookieItem{ - Name: c.Name, - Duration: c.Duration, - Description: c.Description, + Name: p.DisplayName, + Duration: p.Duration, + Description: p.Description, }, ) } @@ -448,10 +448,10 @@ func (s *Service) ensureDraftVersion( scope coredata.Scoper, banner *coredata.CookieBanner, categories coredata.CookieCategories, - allCookies coredata.Cookies, + allPatterns coredata.CookiePatterns, translations coredata.CookieBannerTranslations, ) (*coredata.CookieBannerVersion, error) { - snapshot := buildSnapshot(banner, categories, allCookies, translations) + snapshot := buildSnapshot(banner, categories, allPatterns, translations) var latest coredata.CookieBannerVersion err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID) @@ -514,9 +514,9 @@ func (s *Service) ensureDraftVersionForBanner( return nil, fmt.Errorf("cannot load cookie categories: %w", err) } - var allCookies coredata.Cookies - if err := allCookies.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil { - return nil, fmt.Errorf("cannot load cookies: %w", err) + var allPatterns coredata.CookiePatterns + if err := allPatterns.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil { + return nil, fmt.Errorf("cannot load cookie patterns: %w", err) } var translations coredata.CookieBannerTranslations @@ -524,7 +524,7 @@ func (s *Service) ensureDraftVersionForBanner( return nil, fmt.Errorf("cannot load cookie banner translations: %w", err) } - return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allCookies, translations) + return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns, translations) } func (s *Service) CreateCookieBanner( @@ -594,18 +594,35 @@ func (s *Service) CreateCookieBanner( slugToGID[dc.Slug] = category.ID if dc.Kind == coredata.CookieCategoryKindNecessary { - consentCookie := &coredata.Cookie{ - ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), + consentPattern := &coredata.CookiePattern{ + ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType), OrganizationID: banner.OrganizationID, CookieBannerID: banner.ID, CookieCategoryID: category.ID, - Name: "probo_consent", + Pattern: "probo_consent", + MatchType: coredata.CookiePatternMatchTypeExact, + DisplayName: "probo_consent", Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays), Description: "Stores your cookie consent preferences for this website.", Source: coredata.CookieSourceScript, CreatedAt: now, UpdatedAt: now, } + if err := consentPattern.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert probo_consent pattern: %w", err) + } + + consentCookie := &coredata.Cookie{ + ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), + OrganizationID: banner.OrganizationID, + CookieBannerID: banner.ID, + CookiePatternID: consentPattern.ID, + Name: "probo_consent", + Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays), + Source: coredata.CookieSourceScript, + CreatedAt: now, + UpdatedAt: now, + } if err := consentCookie.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert probo_consent cookie: %w", err) } @@ -1199,12 +1216,14 @@ func (s *Service) CreateCookie( now := time.Now() - cookie = &coredata.Cookie{ - ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), + pattern := &coredata.CookiePattern{ + ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType), OrganizationID: category.OrganizationID, CookieBannerID: category.CookieBannerID, CookieCategoryID: category.ID, - Name: req.Name, + Pattern: req.Name, + MatchType: coredata.CookiePatternMatchTypeExact, + DisplayName: req.Name, Duration: req.Duration, Description: req.Description, Source: coredata.CookieSourceScript, @@ -1212,6 +1231,25 @@ func (s *Service) CreateCookie( UpdatedAt: now, } + if err := pattern.Insert(ctx, tx, scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + return ErrCookieNameAlreadyExists + } + return fmt.Errorf("cannot insert cookie pattern: %w", err) + } + + cookie = &coredata.Cookie{ + ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), + OrganizationID: category.OrganizationID, + CookieBannerID: category.CookieBannerID, + CookiePatternID: pattern.ID, + Name: req.Name, + Duration: req.Duration, + Source: coredata.CookieSourceScript, + CreatedAt: now, + UpdatedAt: now, + } + if err := cookie.Insert(ctx, tx, scope); err != nil { if errors.Is(err, coredata.ErrResourceAlreadyExists) { return ErrCookieNameAlreadyExists @@ -1260,6 +1298,33 @@ func (s *Service) GetCookie( return &cookie, nil } +func (s *Service) GetCookiePattern( + ctx context.Context, + scope coredata.Scoper, + cookiePatternID gid.GID, +) (*coredata.CookiePattern, error) { + var pattern coredata.CookiePattern + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := pattern.LoadByID(ctx, conn, scope, cookiePatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookieNotFound + } + return fmt.Errorf("cannot load cookie pattern: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &pattern, nil +} + func (s *Service) UpdateCookie( ctx context.Context, scope coredata.Scoper, @@ -1281,22 +1346,13 @@ func (s *Service) UpdateCookie( return fmt.Errorf("cannot load cookie: %w", err) } - if req.Name != nil { - cookie.Name = *req.Name - } if req.Duration != nil { cookie.Duration = *req.Duration } - if req.Description != nil { - cookie.Description = *req.Description - } cookie.UpdatedAt = time.Now() if err := cookie.Update(ctx, tx, scope); err != nil { - if errors.Is(err, coredata.ErrResourceAlreadyExists) { - return ErrCookieNameAlreadyExists - } return fmt.Errorf("cannot update cookie: %w", err) } @@ -1354,7 +1410,7 @@ func (s *Service) ListCookiesForCategory( err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := cookies.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor); err != nil { + if err := cookies.LoadByCookieCategoryIDViaPattern(ctx, conn, scope, categoryID, cursor); err != nil { return fmt.Errorf("cannot list cookies: %w", err) } @@ -1381,7 +1437,7 @@ func (s *Service) CountCookiesForCategory( var cookies coredata.Cookies var err error - count, err = cookies.CountByCookieCategoryID(ctx, conn, scope, categoryID) + count, err = cookies.CountByCookieCategoryIDViaPattern(ctx, conn, scope, categoryID) if err != nil { return fmt.Errorf("cannot count cookies: %w", err) } @@ -1500,7 +1556,12 @@ func (s *Service) MoveCookieToCategory( return fmt.Errorf("cannot load target cookie category: %w", err) } - if cookie.CookieCategoryID == target.ID { + var pattern coredata.CookiePattern + if err := pattern.LoadByID(ctx, tx, scope, cookie.CookiePatternID); err != nil { + return fmt.Errorf("cannot load cookie pattern: %w", err) + } + + if pattern.CookieCategoryID == target.ID { return ErrSameCategoryMove } @@ -1508,11 +1569,11 @@ func (s *Service) MoveCookieToCategory( return ErrCategoriesBannerMismatch } - cookie.CookieCategoryID = target.ID - cookie.UpdatedAt = time.Now() + pattern.CookieCategoryID = target.ID + pattern.UpdatedAt = time.Now() - if err := cookie.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update cookie: %w", err) + if err := pattern.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update cookie pattern: %w", err) } var banner coredata.CookieBanner @@ -1611,9 +1672,9 @@ func (s *Service) DeleteCookieCategory( return fmt.Errorf("cannot load uncategorised cookie category: %w", err) } - var cookies coredata.Cookies - if err := cookies.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil { - return fmt.Errorf("cannot move cookies to uncategorised: %w", err) + var patterns coredata.CookiePatterns + if err := patterns.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil { + return fmt.Errorf("cannot move cookie patterns to uncategorised: %w", err) } if err := category.Delete(ctx, tx, scope); err != nil { @@ -2204,26 +2265,53 @@ func (s *Service) ReportDetectedCookies( now := time.Now() for _, dc := range req.Cookies { - cookie := &coredata.Cookie{ - ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), - OrganizationID: banner.OrganizationID, - CookieBannerID: banner.ID, - CookieCategoryID: uncategorised.ID, - Name: dc.Name, - Duration: dc.Duration, - Description: "", - Source: dc.Source, - CreatedAt: now, - UpdatedAt: now, + var matchedPattern coredata.CookiePattern + err := matchedPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, dc.Name) + if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot find matching pattern: %w", err) } - ok, err := cookie.InsertIfNotExists(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot insert detected cookie: %w", err) - } - if ok { + patternID := matchedPattern.ID + if errors.Is(err, coredata.ErrResourceNotFound) { + newPattern := &coredata.CookiePattern{ + ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType), + OrganizationID: banner.OrganizationID, + CookieBannerID: banner.ID, + CookieCategoryID: uncategorised.ID, + Pattern: dc.Name, + MatchType: coredata.CookiePatternMatchTypeExact, + DisplayName: dc.Name, + Duration: dc.Duration, + Description: "", + Source: dc.Source, + CreatedAt: now, + UpdatedAt: now, + } + if err := newPattern.Insert(ctx, tx, scope); err != nil { + if errors.Is(err, coredata.ErrResourceAlreadyExists) { + continue + } + return fmt.Errorf("cannot insert cookie pattern: %w", err) + } + patternID = newPattern.ID inserted++ } + + cookie := &coredata.Cookie{ + ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType), + OrganizationID: banner.OrganizationID, + CookieBannerID: banner.ID, + CookiePatternID: patternID, + Name: dc.Name, + Duration: dc.Duration, + Source: dc.Source, + CreatedAt: now, + UpdatedAt: now, + } + + if _, err := cookie.InsertIfNotExists(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert detected cookie: %w", err) + } } if inserted > 0 { diff --git a/pkg/coredata/cookie.go b/pkg/coredata/cookie.go index 82e954f9c..07bc74822 100644 --- a/pkg/coredata/cookie.go +++ b/pkg/coredata/cookie.go @@ -30,16 +30,15 @@ import ( type ( Cookie struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - CookieBannerID gid.GID `db:"cookie_banner_id"` - CookieCategoryID gid.GID `db:"cookie_category_id"` - Name string `db:"name"` - Duration string `db:"duration"` - Description string `db:"description"` - Source CookieSource `db:"source"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookiePatternID gid.GID `db:"cookie_pattern_id"` + Name string `db:"name"` + Duration string `db:"duration"` + Source CookieSource `db:"source"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } Cookies []*Cookie @@ -80,10 +79,9 @@ SELECT id, organization_id, cookie_banner_id, - cookie_category_id, + cookie_pattern_id, name, duration, - description, source, created_at, updated_at @@ -118,7 +116,85 @@ LIMIT 1; return nil } -func (c *Cookies) LoadByCookieCategoryID( +func (c *Cookies) LoadByCookiePatternID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookiePatternID gid.GID, + cursor *page.Cursor[CookieOrderField], +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_pattern_id, + name, + duration, + source, + created_at, + updated_at +FROM + cookies +WHERE + %s + AND cookie_pattern_id = @cookie_pattern_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookies: %w", err) + } + + cookies, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Cookie]) + if err != nil { + return fmt.Errorf("cannot collect cookies: %w", err) + } + + *c = cookies + + return nil +} + +func (c *Cookies) CountByCookiePatternID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookiePatternID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookies +WHERE + %s + AND cookie_pattern_id = @cookie_pattern_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (c *Cookies) LoadByCookieCategoryIDViaPattern( ctx context.Context, conn pg.Querier, scope Scoper, @@ -130,10 +206,9 @@ SELECT id, organization_id, cookie_banner_id, - cookie_category_id, + cookie_pattern_id, name, duration, - description, source, created_at, updated_at @@ -141,7 +216,9 @@ FROM cookies WHERE %s - AND cookie_category_id = @cookie_category_id + AND cookie_pattern_id IN ( + SELECT id FROM cookie_patterns WHERE cookie_category_id = @cookie_category_id + ) AND %s ` @@ -166,7 +243,7 @@ WHERE return nil } -func (c *Cookies) CountByCookieCategoryID( +func (c *Cookies) CountByCookieCategoryIDViaPattern( ctx context.Context, conn pg.Querier, scope Scoper, @@ -179,7 +256,9 @@ FROM cookies WHERE %s - AND cookie_category_id = @cookie_category_id + AND cookie_pattern_id IN ( + SELECT id FROM cookie_patterns WHERE cookie_category_id = @cookie_category_id + ) ` q = fmt.Sprintf(q, scope.SQLFragment()) @@ -208,10 +287,9 @@ SELECT id, organization_id, cookie_banner_id, - cookie_category_id, + cookie_pattern_id, name, duration, - description, source, created_at, updated_at @@ -255,10 +333,9 @@ INSERT INTO cookies ( tenant_id, organization_id, cookie_banner_id, - cookie_category_id, + cookie_pattern_id, name, duration, - description, source, created_at, updated_at @@ -267,10 +344,9 @@ INSERT INTO cookies ( @tenant_id, @organization_id, @cookie_banner_id, - @cookie_category_id, + @cookie_pattern_id, @name, @duration, - @description, @source, @created_at, @updated_at @@ -278,17 +354,16 @@ INSERT INTO cookies ( ` args := pgx.StrictNamedArgs{ - "id": c.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": c.OrganizationID, - "cookie_banner_id": c.CookieBannerID, - "cookie_category_id": c.CookieCategoryID, - "name": c.Name, - "duration": c.Duration, - "description": c.Description, - "source": c.Source, - "created_at": c.CreatedAt, - "updated_at": c.UpdatedAt, + "id": c.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": c.OrganizationID, + "cookie_banner_id": c.CookieBannerID, + "cookie_pattern_id": c.CookiePatternID, + "name": c.Name, + "duration": c.Duration, + "source": c.Source, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, } _, err := tx.Exec(ctx, q, args) @@ -315,10 +390,9 @@ INSERT INTO cookies ( tenant_id, organization_id, cookie_banner_id, - cookie_category_id, + cookie_pattern_id, name, duration, - description, source, created_at, updated_at @@ -327,10 +401,9 @@ INSERT INTO cookies ( @tenant_id, @organization_id, @cookie_banner_id, - @cookie_category_id, + @cookie_pattern_id, @name, @duration, - @description, @source, @created_at, @updated_at @@ -341,18 +414,17 @@ ON CONFLICT (cookie_banner_id, name) DO UPDATE ` args := pgx.StrictNamedArgs{ - "id": c.ID, - "tenant_id": scope.GetTenantID(), - "organization_id": c.OrganizationID, - "cookie_banner_id": c.CookieBannerID, - "cookie_category_id": c.CookieCategoryID, - "name": c.Name, - "duration": c.Duration, - "description": c.Description, - "source": c.Source, - "source_script": CookieSourceScript, - "created_at": c.CreatedAt, - "updated_at": c.UpdatedAt, + "id": c.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": c.OrganizationID, + "cookie_banner_id": c.CookieBannerID, + "cookie_pattern_id": c.CookiePatternID, + "name": c.Name, + "duration": c.Duration, + "source": c.Source, + "source_script": CookieSourceScript, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, } result, err := tx.Exec(ctx, q, args) @@ -371,10 +443,8 @@ func (c *Cookie) Update( q := ` UPDATE cookies SET - cookie_category_id = @cookie_category_id, - name = @name, + cookie_pattern_id = @cookie_pattern_id, duration = @duration, - description = @description, updated_at = @updated_at WHERE %s @@ -384,22 +454,15 @@ WHERE q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "id": c.ID, - "cookie_category_id": c.CookieCategoryID, - "name": c.Name, - "duration": c.Duration, - "description": c.Description, - "updated_at": c.UpdatedAt, + "id": c.ID, + "cookie_pattern_id": c.CookiePatternID, + "duration": c.Duration, + "updated_at": c.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) result, err := tx.Exec(ctx, q, args) if err != nil { - if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { - if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookies_unique_name_per_banner" { - return ErrResourceAlreadyExists - } - } return fmt.Errorf("cannot update cookie: %w", err) } @@ -435,35 +498,35 @@ WHERE return nil } -func (c *Cookies) MoveToCategoryByCookieCategoryID( +func (c *Cookies) RelinkByCookiePatternID( ctx context.Context, tx pg.Tx, scope Scoper, - sourceCategoryID gid.GID, - targetCategoryID gid.GID, + sourcePatternID gid.GID, + targetPatternID gid.GID, ) error { q := ` UPDATE cookies SET - cookie_category_id = @target_category_id, + cookie_pattern_id = @target_pattern_id, updated_at = @updated_at WHERE %s - AND cookie_category_id = @source_category_id + AND cookie_pattern_id = @source_pattern_id ` q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "source_category_id": sourceCategoryID, - "target_category_id": targetCategoryID, - "updated_at": time.Now(), + "source_pattern_id": sourcePatternID, + "target_pattern_id": targetPatternID, + "updated_at": time.Now(), } maps.Copy(args, scope.SQLArguments()) _, err := tx.Exec(ctx, q, args) if err != nil { - return fmt.Errorf("cannot move cookies to category: %w", err) + return fmt.Errorf("cannot relink cookies to pattern: %w", err) } return nil diff --git a/pkg/coredata/cookie_pattern.go b/pkg/coredata/cookie_pattern.go new file mode 100644 index 000000000..477ea3690 --- /dev/null +++ b/pkg/coredata/cookie_pattern.go @@ -0,0 +1,488 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CookiePattern struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookieCategoryID gid.GID `db:"cookie_category_id"` + Pattern string `db:"pattern"` + MatchType CookiePatternMatchType `db:"match_type"` + DisplayName string `db:"display_name"` + Duration string `db:"duration"` + Description string `db:"description"` + Source CookieSource `db:"source"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + CookiePatterns []*CookiePattern +) + +func (cp *CookiePattern) CursorKey(field CookiePatternOrderField) page.CursorKey { + switch field { + case CookiePatternOrderFieldCreatedAt: + return page.NewCursorKey(cp.ID, cp.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (cp *CookiePattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM cookie_patterns WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, cp.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + + return nil, fmt.Errorf("cannot query cookie pattern authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (cp *CookiePattern) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookiePatternID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + pattern, + match_type, + display_name, + duration, + description, + source, + created_at, + updated_at +FROM + cookie_patterns +WHERE + %s + AND id = @cookie_pattern_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie patterns: %w", err) + } + + pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie pattern: %w", err) + } + + *cp = pattern + + return nil +} + +func (cp *CookiePattern) FindMatchingPattern( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cookieName string, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + pattern, + match_type, + display_name, + duration, + description, + source, + created_at, + updated_at +FROM + cookie_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND ( + (match_type = @match_type_prefix AND starts_with(@cookie_name, pattern)) + OR (match_type = @match_type_exact AND pattern = @cookie_name) + ) +ORDER BY + CASE WHEN match_type = @match_type_prefix THEN 0 ELSE 1 END +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "cookie_name": cookieName, + "match_type_prefix": CookiePatternMatchTypePrefix, + "match_type_exact": CookiePatternMatchTypeExact, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie patterns: %w", err) + } + + pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie pattern: %w", err) + } + + *cp = pattern + + return nil +} + +func (cps *CookiePatterns) LoadByCookieCategoryID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieCategoryID gid.GID, + cursor *page.Cursor[CookiePatternOrderField], +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + pattern, + match_type, + display_name, + duration, + description, + source, + created_at, + updated_at +FROM + cookie_patterns +WHERE + %s + AND cookie_category_id = @cookie_category_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie patterns: %w", err) + } + + patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern]) + if err != nil { + return fmt.Errorf("cannot collect cookie patterns: %w", err) + } + + *cps = patterns + + return nil +} + +func (cps *CookiePatterns) CountByCookieCategoryID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieCategoryID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_patterns +WHERE + %s + AND cookie_category_id = @cookie_category_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (cps *CookiePatterns) LoadAllByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + pattern, + match_type, + display_name, + duration, + description, + source, + created_at, + updated_at +FROM + cookie_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id +ORDER BY + created_at ASC, id ASC; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie patterns: %w", err) + } + + patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern]) + if err != nil { + return fmt.Errorf("cannot collect cookie patterns: %w", err) + } + + *cps = patterns + + return nil +} + +func (cp *CookiePattern) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO cookie_patterns ( + id, + tenant_id, + organization_id, + cookie_banner_id, + cookie_category_id, + pattern, + match_type, + display_name, + duration, + description, + source, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @cookie_banner_id, + @cookie_category_id, + @pattern, + @match_type, + @display_name, + @duration, + @description, + @source, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": cp.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": cp.OrganizationID, + "cookie_banner_id": cp.CookieBannerID, + "cookie_category_id": cp.CookieCategoryID, + "pattern": cp.Pattern, + "match_type": cp.MatchType, + "display_name": cp.DisplayName, + "duration": cp.Duration, + "description": cp.Description, + "source": cp.Source, + "created_at": cp.CreatedAt, + "updated_at": cp.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_patterns_unique_pattern_per_banner" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot insert cookie pattern: %w", err) + } + + return nil +} + +func (cp *CookiePattern) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE cookie_patterns +SET + cookie_category_id = @cookie_category_id, + display_name = @display_name, + duration = @duration, + description = @description, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": cp.ID, + "cookie_category_id": cp.CookieCategoryID, + "display_name": cp.DisplayName, + "duration": cp.Duration, + "description": cp.Description, + "updated_at": cp.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_patterns_unique_pattern_per_banner" { + return ErrResourceAlreadyExists + } + } + return fmt.Errorf("cannot update cookie pattern: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} + +func (cp *CookiePattern) Delete( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM cookie_patterns +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": cp.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete cookie pattern: %w", err) + } + + return nil +} + +func (cps *CookiePatterns) MoveToCategoryByCookieCategoryID( + ctx context.Context, + tx pg.Tx, + scope Scoper, + sourceCategoryID gid.GID, + targetCategoryID gid.GID, +) error { + q := ` +UPDATE cookie_patterns +SET + cookie_category_id = @target_category_id, + updated_at = @updated_at +WHERE + %s + AND cookie_category_id = @source_category_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "source_category_id": sourceCategoryID, + "target_category_id": targetCategoryID, + "updated_at": time.Now(), + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot move cookie patterns to category: %w", err) + } + + return nil +} diff --git a/pkg/coredata/cookie_pattern_match_type.go b/pkg/coredata/cookie_pattern_match_type.go new file mode 100644 index 000000000..7e5cf321c --- /dev/null +++ b/pkg/coredata/cookie_pattern_match_type.go @@ -0,0 +1,70 @@ +// 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 coredata + +import ( + "database/sql/driver" + "fmt" +) + +type CookiePatternMatchType string + +const ( + CookiePatternMatchTypeExact CookiePatternMatchType = "EXACT" + CookiePatternMatchTypePrefix CookiePatternMatchType = "PREFIX" +) + +func CookiePatternMatchTypes() []CookiePatternMatchType { + return []CookiePatternMatchType{ + CookiePatternMatchTypeExact, + CookiePatternMatchTypePrefix, + } +} + +func (m CookiePatternMatchType) String() string { + return string(m) +} + +func (m *CookiePatternMatchType) Scan(value any) error { + var v string + switch val := value.(type) { + case string: + v = val + case []byte: + v = string(val) + default: + return fmt.Errorf("unsupported type for CookiePatternMatchType: %T", value) + } + + switch CookiePatternMatchType(v) { + case CookiePatternMatchTypeExact: + *m = CookiePatternMatchTypeExact + case CookiePatternMatchTypePrefix: + *m = CookiePatternMatchTypePrefix + default: + return fmt.Errorf("invalid CookiePatternMatchType value: %q", v) + } + return nil +} + +func (m CookiePatternMatchType) Value() (driver.Value, error) { + switch m { + case CookiePatternMatchTypeExact, + CookiePatternMatchTypePrefix: + return string(m), nil + default: + return nil, fmt.Errorf("invalid CookiePatternMatchType: %s", m) + } +} diff --git a/pkg/coredata/cookie_pattern_order_field.go b/pkg/coredata/cookie_pattern_order_field.go new file mode 100644 index 000000000..ed57c7390 --- /dev/null +++ b/pkg/coredata/cookie_pattern_order_field.go @@ -0,0 +1,55 @@ +// 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 coredata + +import "fmt" + +type CookiePatternOrderField string + +const ( + CookiePatternOrderFieldCreatedAt CookiePatternOrderField = "CREATED_AT" +) + +func (p CookiePatternOrderField) Column() string { + switch p { + case CookiePatternOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p CookiePatternOrderField) IsValid() bool { + switch p { + case CookiePatternOrderFieldCreatedAt: + return true + } + return false +} + +func (p CookiePatternOrderField) String() string { + return string(p) +} + +func (p *CookiePatternOrderField) UnmarshalText(text []byte) error { + *p = CookiePatternOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid CookiePatternOrderField", string(text)) + } + return nil +} + +func (p CookiePatternOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index c2492478b..a1c883b46 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -111,6 +111,7 @@ const ( CookieEntityType uint16 = 85 CookieBannerTranslationEntityType uint16 = 86 AgentRunEntityType uint16 = 87 + CookiePatternEntityType uint16 = 88 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -281,6 +282,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &CookieBannerTranslation{ID: id}, true case AgentRunEntityType: return &AgentRun{ID: id}, true + case CookiePatternEntityType: + return &CookiePattern{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260429T084452Z.sql b/pkg/coredata/migrations/20260429T084452Z.sql new file mode 100644 index 000000000..49b929060 --- /dev/null +++ b/pkg/coredata/migrations/20260429T084452Z.sql @@ -0,0 +1,63 @@ +-- 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. + +CREATE TYPE cookie_pattern_match_type AS ENUM ('EXACT', 'PREFIX'); + +CREATE TABLE cookie_patterns ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE, + cookie_category_id TEXT NOT NULL REFERENCES cookie_categories(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + match_type cookie_pattern_match_type NOT NULL, + display_name TEXT NOT NULL, + duration TEXT NOT NULL, + description TEXT NOT NULL, + source cookie_source NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE UNIQUE INDEX idx_cookie_patterns_unique_pattern_per_banner + ON cookie_patterns (cookie_banner_id, pattern); + +-- Backfill: create an EXACT pattern for each existing cookie +INSERT INTO cookie_patterns ( + id, tenant_id, organization_id, cookie_banner_id, + cookie_category_id, pattern, match_type, display_name, + duration, description, source, created_at, updated_at +) +SELECT + generate_gid(decode_base64_unpadded(c.tenant_id), 88), + c.tenant_id, c.organization_id, c.cookie_banner_id, + c.cookie_category_id, c.name, 'EXACT', c.name, + c.duration, c.description, c.source, c.created_at, c.updated_at +FROM cookies c; + +-- Link each cookie to its pattern +ALTER TABLE cookies ADD COLUMN cookie_pattern_id TEXT REFERENCES cookie_patterns(id) ON DELETE CASCADE; + +UPDATE cookies c +SET cookie_pattern_id = cp.id +FROM cookie_patterns cp +WHERE cp.cookie_banner_id = c.cookie_banner_id + AND cp.pattern = c.name + AND cp.match_type = 'EXACT'; + +ALTER TABLE cookies ALTER COLUMN cookie_pattern_id SET NOT NULL; + +-- Category and description now live on the pattern +ALTER TABLE cookies DROP COLUMN cookie_category_id; +ALTER TABLE cookies DROP COLUMN description; diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index 83b7b4c83..33660c71c 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -319,7 +319,7 @@ func (r *cookieCategoryResolver) Cookies(ctx context.Context, obj *types.CookieC p := page.NewPage(cookies, cursor) - return types.NewCookieConnection(p, r, obj.ID), nil + return types.NewCookieConnection(p, r, obj.ID, obj.ID), nil } // Permission is the resolver for the permission field. @@ -759,7 +759,7 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types } return &types.MoveCookieToCategoryPayload{ - Cookie: types.NewCookie(result.Cookie), + Cookie: types.NewCookie(result.Cookie, input.TargetCookieCategoryID), CookieBanner: types.NewCookieBanner(result.Banner), }, nil } @@ -804,7 +804,7 @@ func (r *mutationResolver) CreateCookie(ctx context.Context, input types.CreateC } return &types.CreateCookiePayload{ - CookieEdge: types.NewCookieEdge(cookie, coredata.CookieOrderFieldCreatedAt), + CookieEdge: types.NewCookieEdge(cookie, coredata.CookieOrderFieldCreatedAt, input.CookieCategoryID), CookieBanner: types.NewCookieBanner(banner), }, nil } @@ -841,6 +841,13 @@ func (r *mutationResolver) UpdateCookie(ctx context.Context, input types.UpdateC return nil, gqlutils.Internal(ctx) } + patternScope := coredata.NewScopeFromObjectID(cookie.CookiePatternID) + pattern, err := r.cookieBanner.GetCookiePattern(ctx, patternScope, cookie.CookiePatternID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + bannerScope := coredata.NewScopeFromObjectID(cookie.CookieBannerID) banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, cookie.CookieBannerID) if err != nil { @@ -849,7 +856,7 @@ func (r *mutationResolver) UpdateCookie(ctx context.Context, input types.UpdateC } return &types.UpdateCookiePayload{ - Cookie: types.NewCookie(cookie), + Cookie: types.NewCookie(cookie, pattern.CookieCategoryID), CookieBanner: types.NewCookieBanner(banner), }, nil } diff --git a/pkg/server/api/console/v1/types/cookie.go b/pkg/server/api/console/v1/types/cookie.go index 5fa895696..69d661161 100644 --- a/pkg/server/api/console/v1/types/cookie.go +++ b/pkg/server/api/console/v1/types/cookie.go @@ -37,11 +37,12 @@ func NewCookieConnection( p *page.Page[*coredata.Cookie, coredata.CookieOrderField], parentType any, parentID gid.GID, + cookieCategoryID gid.GID, ) *CookieConnection { edges := make([]*CookieEdge, len(p.Data)) for i := range edges { - edges[i] = NewCookieEdge(p.Data[i], p.Cursor.OrderBy.Field) + edges[i] = NewCookieEdge(p.Data[i], p.Cursor.OrderBy.Field, cookieCategoryID) } return &CookieConnection{ @@ -53,27 +54,26 @@ func NewCookieConnection( } } -func NewCookieEdge(c *coredata.Cookie, orderBy coredata.CookieOrderField) *CookieEdge { +func NewCookieEdge(c *coredata.Cookie, orderBy coredata.CookieOrderField, cookieCategoryID gid.GID) *CookieEdge { return &CookieEdge{ Cursor: c.CursorKey(orderBy), - Node: NewCookie(c), + Node: NewCookie(c, cookieCategoryID), } } -func NewCookie(c *coredata.Cookie) *Cookie { +func NewCookie(c *coredata.Cookie, cookieCategoryID gid.GID) *Cookie { return &Cookie{ ID: c.ID, CookieCategory: &CookieCategory{ - ID: c.CookieCategoryID, + ID: cookieCategoryID, CookieBanner: &CookieBanner{ ID: c.CookieBannerID, }, }, - Name: c.Name, - Duration: c.Duration, - Description: c.Description, - Source: c.Source, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + Name: c.Name, + Duration: c.Duration, + Source: c.Source, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, } }