Add new models

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-09 13:58:31 +04:00
parent 43336078f4
commit e9330171fe
11 changed files with 1419 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
}
}

View File

@@ -0,0 +1,396 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -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
}