diff --git a/pkg/coredata/cookie_banner.go b/pkg/coredata/cookie_banner.go new file mode 100644 index 000000000..26c6b1e87 --- /dev/null +++ b/pkg/coredata/cookie_banner.go @@ -0,0 +1,366 @@ +// Copyright (c) 2025-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" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CookieBanner struct { + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + Name string `db:"name"` + Origin string `db:"origin"` + State CookieBannerState `db:"state"` + PrivacyPolicyURL string `db:"privacy_policy_url"` + ConsentExpiryDays int `db:"consent_expiry_days"` + ConsentMode CookieConsentMode `db:"consent_mode"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + CookieBanners []*CookieBanner +) + +func (b *CookieBanner) CursorKey(field CookieBannerOrderField) page.CursorKey { + switch field { + case CookieBannerOrderFieldCreatedAt: + return page.NewCursorKey(b.ID, b.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (b *CookieBanner) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := `SELECT organization_id FROM cookie_banners WHERE id = $1 LIMIT 1;` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, b.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + + return nil, fmt.Errorf("cannot query cookie banner authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (b *CookieBanner) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + bannerID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + name, + origin, + state, + privacy_policy_url, + consent_expiry_days, + consent_mode, + created_at, + updated_at +FROM + cookie_banners +WHERE + %s + AND id = @banner_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"banner_id": bannerID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banners: %w", err) + } + + banner, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieBanner]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect cookie banner: %w", err) + } + + *b = banner + + return nil +} + +func (b *CookieBanner) LoadPublishedByID( + ctx context.Context, + conn pg.Querier, + bannerID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + name, + origin, + state, + privacy_policy_url, + consent_expiry_days, + consent_mode, + created_at, + updated_at +FROM + cookie_banners +WHERE + id = @banner_id + AND state = 'PUBLISHED' +LIMIT 1; +` + + args := pgx.StrictNamedArgs{"banner_id": bannerID} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banners: %w", err) + } + + banner, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieBanner]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect cookie banner: %w", err) + } + + *b = banner + + return nil +} + +func (b *CookieBanners) LoadByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, + cursor *page.Cursor[CookieBannerOrderField], +) error { + q := ` +SELECT + id, + organization_id, + name, + origin, + state, + privacy_policy_url, + consent_expiry_days, + consent_mode, + created_at, + updated_at +FROM + cookie_banners +WHERE + %s + AND organization_id = @organization_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + 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 banners: %w", err) + } + + banners, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookieBanner]) + if err != nil { + return fmt.Errorf("cannot collect cookie banners: %w", err) + } + + *b = banners + + return nil +} + +func (b *CookieBanners) CountByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_banners +WHERE + %s + AND organization_id = @organization_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + 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 (b *CookieBanner) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO cookie_banners ( + id, + tenant_id, + organization_id, + name, + origin, + state, + privacy_policy_url, + consent_expiry_days, + consent_mode, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @organization_id, + @name, + @origin, + @state, + @privacy_policy_url, + @consent_expiry_days, + @consent_mode, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": b.ID, + "tenant_id": scope.GetTenantID(), + "organization_id": b.OrganizationID, + "name": b.Name, + "origin": b.Origin, + "state": b.State, + "privacy_policy_url": b.PrivacyPolicyURL, + "consent_expiry_days": b.ConsentExpiryDays, + "consent_mode": b.ConsentMode, + "created_at": b.CreatedAt, + "updated_at": b.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert cookie banner: %w", err) + } + + return nil +} + +func (b *CookieBanner) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE cookie_banners +SET + name = @name, + origin = @origin, + state = @state, + privacy_policy_url = @privacy_policy_url, + consent_expiry_days = @consent_expiry_days, + consent_mode = @consent_mode, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": b.ID, + "name": b.Name, + "origin": b.Origin, + "state": b.State, + "privacy_policy_url": b.PrivacyPolicyURL, + "consent_expiry_days": b.ConsentExpiryDays, + "consent_mode": b.ConsentMode, + "updated_at": b.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update cookie banner: %w", err) + } + + return nil +} + +func (b *CookieBanner) Delete( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM cookie_banners +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": b.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete cookie banner: %w", err) + } + + return nil +} diff --git a/pkg/coredata/cookie_banner_order_field.go b/pkg/coredata/cookie_banner_order_field.go new file mode 100644 index 000000000..52dd47393 --- /dev/null +++ b/pkg/coredata/cookie_banner_order_field.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-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 CookieBannerOrderField string + +const ( + CookieBannerOrderFieldCreatedAt CookieBannerOrderField = "CREATED_AT" +) + +func (p CookieBannerOrderField) Column() string { + switch p { + case CookieBannerOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p CookieBannerOrderField) IsValid() bool { + switch p { + case CookieBannerOrderFieldCreatedAt: + return true + } + return false +} + +func (p CookieBannerOrderField) String() string { + return string(p) +} + +func (p *CookieBannerOrderField) UnmarshalText(text []byte) error { + *p = CookieBannerOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid CookieBannerOrderField", string(text)) + } + return nil +} + +func (p CookieBannerOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/cookie_banner_state.go b/pkg/coredata/cookie_banner_state.go new file mode 100644 index 000000000..5ac6efc75 --- /dev/null +++ b/pkg/coredata/cookie_banner_state.go @@ -0,0 +1,75 @@ +// Copyright (c) 2025-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 CookieBannerState string + +const ( + CookieBannerStateDraft CookieBannerState = "DRAFT" + CookieBannerStatePublished CookieBannerState = "PUBLISHED" + CookieBannerStateDisabled CookieBannerState = "DISABLED" +) + +func CookieBannerStates() []CookieBannerState { + return []CookieBannerState{ + CookieBannerStateDraft, + CookieBannerStatePublished, + CookieBannerStateDisabled, + } +} + +func (s CookieBannerState) String() string { + return string(s) +} + +func (s *CookieBannerState) 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 CookieBannerState: %T", value) + } + + switch CookieBannerState(v) { + case CookieBannerStateDraft: + *s = CookieBannerStateDraft + case CookieBannerStatePublished: + *s = CookieBannerStatePublished + case CookieBannerStateDisabled: + *s = CookieBannerStateDisabled + default: + return fmt.Errorf("invalid CookieBannerState value: %q", v) + } + return nil +} + +func (s CookieBannerState) Value() (driver.Value, error) { + switch s { + case CookieBannerStateDraft, + CookieBannerStatePublished, + CookieBannerStateDisabled: + return string(s), nil + default: + return nil, fmt.Errorf("invalid CookieBannerState: %s", s) + } +} diff --git a/pkg/coredata/cookie_category.go b/pkg/coredata/cookie_category.go new file mode 100644 index 000000000..314a6549d --- /dev/null +++ b/pkg/coredata/cookie_category.go @@ -0,0 +1,396 @@ +// Copyright (c) 2025-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" + "encoding/json" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CookieItem struct { + Name string `json:"name"` + Duration string `json:"duration"` + Description string `json:"description"` + } + + CookieItems []CookieItem + + CookieCategory struct { + ID gid.GID `db:"id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + Name string `db:"name"` + Description string `db:"description"` + Required bool `db:"required"` + Rank int `db:"rank"` + Cookies CookieItems `db:"cookies"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + CookieCategories []*CookieCategory +) + +func (c CookieItems) MarshalJSON() ([]byte, error) { + if c == nil { + return []byte("[]"), nil + } + return json.Marshal([]CookieItem(c)) +} + +func (c *CookieItems) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + *c = CookieItems{} + return nil + } + return json.Unmarshal(data, (*[]CookieItem)(c)) +} + +func (c *CookieCategory) CursorKey(field CookieCategoryOrderField) page.CursorKey { + switch field { + case CookieCategoryOrderFieldRank: + return page.NewCursorKey(c.ID, c.Rank) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (c *CookieCategory) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := ` +SELECT cb.organization_id +FROM cookie_categories cc +JOIN cookie_banners cb ON cc.cookie_banner_id = cb.id +WHERE cc.id = $1 +LIMIT 1; +` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query cookie category authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (c *CookieCategory) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + categoryID gid.GID, +) error { + q := ` +SELECT + id, + cookie_banner_id, + name, + description, + required, + rank, + cookies, + created_at, + updated_at +FROM + cookie_categories +WHERE + %s + AND id = @category_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"category_id": categoryID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie categories: %w", err) + } + + category, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieCategory]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie category: %w", err) + } + + *c = category + + return nil +} + +func (c *CookieCategories) LoadByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cursor *page.Cursor[CookieCategoryOrderField], +) error { + q := ` +SELECT + id, + cookie_banner_id, + name, + description, + required, + rank, + cookies, + created_at, + updated_at +FROM + cookie_categories +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + 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 categories: %w", err) + } + + categories, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookieCategory]) + if err != nil { + return fmt.Errorf("cannot collect cookie categories: %w", err) + } + + *c = categories + + return nil +} + +func (c *CookieCategories) CountByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_categories +WHERE + %s + AND cookie_banner_id = @cookie_banner_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + 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 *CookieCategories) LoadAllPublicByCookieBannerID( + ctx context.Context, + conn pg.Querier, + cookieBannerID gid.GID, +) error { + q := ` +SELECT + id, + cookie_banner_id, + name, + description, + required, + rank, + cookies, + created_at, + updated_at +FROM + cookie_categories +WHERE + cookie_banner_id = @cookie_banner_id +ORDER BY + rank ASC, id ASC; +` + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie categories: %w", err) + } + + categories, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookieCategory]) + if err != nil { + return fmt.Errorf("cannot collect cookie categories: %w", err) + } + + *c = categories + + return nil +} + +func (c *CookieCategory) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO cookie_categories ( + id, + tenant_id, + cookie_banner_id, + name, + description, + required, + rank, + cookies, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @cookie_banner_id, + @name, + @description, + @required, + @rank, + @cookies, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": c.ID, + "tenant_id": scope.GetTenantID(), + "cookie_banner_id": c.CookieBannerID, + "name": c.Name, + "description": c.Description, + "required": c.Required, + "rank": c.Rank, + "cookies": c.Cookies, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert cookie category: %w", err) + } + + return nil +} + +func (c *CookieCategory) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE cookie_categories +SET + name = @name, + description = @description, + rank = @rank, + cookies = @cookies, + updated_at = @updated_at +WHERE + %s + AND id = @id +RETURNING + id, + cookie_banner_id, + name, + description, + required, + rank, + cookies, + created_at, + updated_at +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": c.ID, + "name": c.Name, + "description": c.Description, + "rank": c.Rank, + "cookies": c.Cookies, + "updated_at": c.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := tx.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update cookie category: %w", err) + } + + category, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieCategory]) + if err != nil { + return fmt.Errorf("cannot collect updated cookie category: %w", err) + } + + *c = category + + return nil +} + +func (c *CookieCategory) Delete( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +DELETE FROM cookie_categories +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": c.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete cookie category: %w", err) + } + + return nil +} diff --git a/pkg/coredata/cookie_category_order_field.go b/pkg/coredata/cookie_category_order_field.go new file mode 100644 index 000000000..eef02dad0 --- /dev/null +++ b/pkg/coredata/cookie_category_order_field.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-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 CookieCategoryOrderField string + +const ( + CookieCategoryOrderFieldRank CookieCategoryOrderField = "RANK" +) + +func (p CookieCategoryOrderField) Column() string { + switch p { + case CookieCategoryOrderFieldRank: + return "rank" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p CookieCategoryOrderField) IsValid() bool { + switch p { + case CookieCategoryOrderFieldRank: + return true + } + return false +} + +func (p CookieCategoryOrderField) String() string { + return string(p) +} + +func (p *CookieCategoryOrderField) UnmarshalText(text []byte) error { + *p = CookieCategoryOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid CookieCategoryOrderField", string(text)) + } + return nil +} + +func (p CookieCategoryOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/cookie_consent_action.go b/pkg/coredata/cookie_consent_action.go new file mode 100644 index 000000000..005b2eeff --- /dev/null +++ b/pkg/coredata/cookie_consent_action.go @@ -0,0 +1,82 @@ +// Copyright (c) 2025-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 CookieConsentAction string + +const ( + CookieConsentActionAcceptAll CookieConsentAction = "ACCEPT_ALL" + CookieConsentActionRejectAll CookieConsentAction = "REJECT_ALL" + CookieConsentActionCustomize CookieConsentAction = "CUSTOMIZE" + // Global Privacy Control + CookieConsentActionGPC CookieConsentAction = "GPC" +) + +func CookieConsentActions() []CookieConsentAction { + return []CookieConsentAction{ + CookieConsentActionAcceptAll, + CookieConsentActionRejectAll, + CookieConsentActionCustomize, + CookieConsentActionGPC, + } +} + +func (a CookieConsentAction) String() string { + return string(a) +} + +func (a *CookieConsentAction) 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 CookieConsentAction: %T", value) + } + + switch CookieConsentAction(v) { + case CookieConsentActionAcceptAll: + *a = CookieConsentActionAcceptAll + case CookieConsentActionRejectAll: + *a = CookieConsentActionRejectAll + case CookieConsentActionCustomize: + *a = CookieConsentActionCustomize + case CookieConsentActionGPC: + *a = CookieConsentActionGPC + default: + return fmt.Errorf("invalid CookieConsentAction value: %q", v) + } + + return nil +} + +func (a CookieConsentAction) Value() (driver.Value, error) { + switch a { + case CookieConsentActionAcceptAll, + CookieConsentActionRejectAll, + CookieConsentActionCustomize, + CookieConsentActionGPC: + return string(a), nil + default: + return nil, fmt.Errorf("invalid CookieConsentAction: %s", a) + } +} diff --git a/pkg/coredata/cookie_consent_mode.go b/pkg/coredata/cookie_consent_mode.go new file mode 100644 index 000000000..b6002d79d --- /dev/null +++ b/pkg/coredata/cookie_consent_mode.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025-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 CookieConsentMode string + +const ( + CookieConsentModeOptIn CookieConsentMode = "OPT_IN" + CookieConsentModeOptOut CookieConsentMode = "OPT_OUT" +) + +func CookieConsentModes() []CookieConsentMode { + return []CookieConsentMode{ + CookieConsentModeOptIn, + CookieConsentModeOptOut, + } +} + +func (m CookieConsentMode) String() string { + return string(m) +} + +func (m *CookieConsentMode) 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 CookieConsentMode: %T", value) + } + + switch CookieConsentMode(v) { + case CookieConsentModeOptIn: + *m = CookieConsentModeOptIn + case CookieConsentModeOptOut: + *m = CookieConsentModeOptOut + default: + return fmt.Errorf("invalid CookieConsentMode value: %q", v) + } + + return nil +} + +func (m CookieConsentMode) Value() (driver.Value, error) { + switch m { + case CookieConsentModeOptIn, + CookieConsentModeOptOut: + return string(m), nil + default: + return nil, fmt.Errorf("invalid CookieConsentMode: %s", m) + } +} diff --git a/pkg/coredata/cookie_consent_record.go b/pkg/coredata/cookie_consent_record.go new file mode 100644 index 000000000..be46379c9 --- /dev/null +++ b/pkg/coredata/cookie_consent_record.go @@ -0,0 +1,205 @@ +// Copyright (c) 2025-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" + "encoding/json" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + ConsentRecord struct { + ID gid.GID `db:"id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + VisitorID string `db:"visitor_id"` + IPAddress *string `db:"ip_address"` + UserAgent *string `db:"user_agent"` + ConsentData json.RawMessage `db:"consent_data"` + Action CookieConsentAction `db:"action"` + CreatedAt time.Time `db:"created_at"` + } + + ConsentRecords []*ConsentRecord +) + +func (r *ConsentRecord) CursorKey(field CookieConsentRecordOrderField) page.CursorKey { + switch field { + case CookieConsentRecordOrderFieldCreatedAt: + return page.NewCursorKey(r.ID, r.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (r *ConsentRecord) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := ` +SELECT cb.organization_id +FROM cookie_consent_records cr +JOIN cookie_banners cb ON cr.cookie_banner_id = cb.id +WHERE cr.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 *ConsentRecords) LoadByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cursor *page.Cursor[CookieConsentRecordOrderField], + filter *CookieConsentRecordFilter, +) error { + q := ` +SELECT + id, + cookie_banner_id, + visitor_id, + ip_address, + user_agent, + consent_data, + action, + created_at +FROM + cookie_consent_records +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query consent records: %w", err) + } + + records, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ConsentRecord]) + if err != nil { + return fmt.Errorf("cannot collect consent records: %w", err) + } + + *r = records + + return nil +} + +func (r *ConsentRecords) CountByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *CookieConsentRecordFilter, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_consent_records +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.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 (r *ConsentRecord) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO cookie_consent_records ( + id, + tenant_id, + cookie_banner_id, + visitor_id, + ip_address, + user_agent, + consent_data, + action, + created_at +) VALUES ( + @id, + @tenant_id, + @cookie_banner_id, + @visitor_id, + @ip_address, + @user_agent, + @consent_data, + @action, + @created_at +) +` + + args := pgx.StrictNamedArgs{ + "id": r.ID, + "tenant_id": scope.GetTenantID(), + "cookie_banner_id": r.CookieBannerID, + "visitor_id": r.VisitorID, + "ip_address": r.IPAddress, + "user_agent": r.UserAgent, + "consent_data": r.ConsentData, + "action": r.Action, + "created_at": r.CreatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert consent record: %w", err) + } + + return nil +} diff --git a/pkg/coredata/cookie_consent_record_filter.go b/pkg/coredata/cookie_consent_record_filter.go new file mode 100644 index 000000000..35f8ab3a6 --- /dev/null +++ b/pkg/coredata/cookie_consent_record_filter.go @@ -0,0 +1,50 @@ +// Copyright (c) 2025-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 "github.com/jackc/pgx/v5" + +type CookieConsentRecordFilter struct { + action *CookieConsentAction +} + +func NewCookieConsentRecordFilter(action *CookieConsentAction) *CookieConsentRecordFilter { + return &CookieConsentRecordFilter{ + action: action, + } +} + +func (f *CookieConsentRecordFilter) SQLFragment() string { + return ` +( + CASE + WHEN @filter_action::text IS NOT NULL THEN + action = @filter_action::consent_action + ELSE TRUE + END +)` +} + +func (f *CookieConsentRecordFilter) SQLArguments() pgx.StrictNamedArgs { + args := pgx.StrictNamedArgs{ + "filter_action": nil, + } + + if f.action != nil { + args["filter_action"] = string(*f.action) + } + + return args +} diff --git a/pkg/coredata/cookie_consent_record_order_field.go b/pkg/coredata/cookie_consent_record_order_field.go new file mode 100644 index 000000000..7fc2e2899 --- /dev/null +++ b/pkg/coredata/cookie_consent_record_order_field.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-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 CookieConsentRecordOrderField string + +const ( + CookieConsentRecordOrderFieldCreatedAt CookieConsentRecordOrderField = "CREATED_AT" +) + +func (p CookieConsentRecordOrderField) Column() string { + switch p { + case CookieConsentRecordOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p CookieConsentRecordOrderField) IsValid() bool { + switch p { + case CookieConsentRecordOrderFieldCreatedAt: + return true + } + return false +} + +func (p CookieConsentRecordOrderField) String() string { + return string(p) +} + +func (p *CookieConsentRecordOrderField) UnmarshalText(text []byte) error { + *p = CookieConsentRecordOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid CookieConsentRecordOrderField", string(text)) + } + return nil +} + +func (p CookieConsentRecordOrderField) 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 64dbd833d..011df7acb 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -98,6 +98,9 @@ const ( AccessReviewCampaignEntityType uint16 = 72 AccessEntryEntityType uint16 = 73 AccessEntryDecisionHistoryEntityType uint16 = 74 + CookieBannerEntityType uint16 = 75 + CookieCategoryEntityType uint16 = 76 + ConsentRecordEntityType uint16 = 77 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -244,6 +247,12 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &AccessEntry{ID: id}, true case AccessEntryDecisionHistoryEntityType: return &AccessEntryDecisionHistory{ID: id}, true + case CookieBannerEntityType: + return &CookieBanner{ID: id}, true + case CookieCategoryEntityType: + return &CookieCategory{ID: id}, true + case ConsentRecordEntityType: + return &ConsentRecord{ID: id}, true default: return nil, false }