// Copyright (c) 2025-2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // 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/iam/policy" "go.probo.inc/probo/pkg/page" ) type ( CookieConsentRecord struct { ID gid.GID `db:"id"` OrganizationID gid.GID `db:"organization_id"` CookieBannerID gid.GID `db:"cookie_banner_id"` CookieBannerVersionID gid.GID `db:"cookie_banner_version_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"` SdkVersion string `db:"sdk_version"` Regulation *Regulation `db:"regulation"` RegulationSource *RegulationSource `db:"regulation_source"` CountryCode *CountryCode `db:"country_code"` ConsentMode *CookieConsentMode `db:"consent_mode"` CreatedAt time.Time `db:"created_at"` } CookieConsentRecords []*CookieConsentRecord ) func (r *CookieConsentRecord) 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 *CookieConsentRecord) AuthorizationAttributes( ctx context.Context, conn pg.Querier, resourceIDs []gid.GID, ) (policy.AttributesByID, error) { q := `SELECT id, organization_id FROM cookie_consent_records WHERE id = ANY(@resource_ids::text[])` args := pgx.StrictNamedArgs{ "resource_ids": resourceIDs, } rows, err := conn.Query(ctx, q, args) if err != nil { return nil, fmt.Errorf("cannot query authorization attributes: %w", err) } defer rows.Close() attrsByID := make(policy.AttributesByID) for rows.Next() { var id, organizationID gid.GID if err := rows.Scan(&id, &organizationID); err != nil { return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) } attrsByID[id] = policy.Attributes{ "organization_id": organizationID.String(), } } if err := rows.Err(); err != nil { return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) } return attrsByID, nil } func (r *CookieConsentRecords) LoadByCookieBannerID( ctx context.Context, conn pg.Querier, scope Scoper, cookieBannerID gid.GID, cursor *page.Cursor[CookieConsentRecordOrderField], filter *CookieConsentRecordFilter, ) error { q := ` SELECT id, organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, ip_address, user_agent, consent_data, action, sdk_version, regulation, regulation_source, country_code, consent_mode, 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[CookieConsentRecord]) if err != nil { return fmt.Errorf("cannot collect consent records: %w", err) } *r = records return nil } func (r *CookieConsentRecords) 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 *CookieConsentRecord) Insert( ctx context.Context, tx pg.Tx, scope Scoper, ) error { q := ` INSERT INTO cookie_consent_records ( id, tenant_id, organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, ip_address, user_agent, consent_data, action, sdk_version, regulation, regulation_source, country_code, consent_mode, created_at ) VALUES ( @id, @tenant_id, @organization_id, @cookie_banner_id, @cookie_banner_version_id, @visitor_id, @ip_address, @user_agent, @consent_data, @action, @sdk_version, @regulation, @regulation_source, @country_code, @consent_mode, @created_at ) ` args := pgx.StrictNamedArgs{ "id": r.ID, "tenant_id": scope.GetTenantID(), "organization_id": r.OrganizationID, "cookie_banner_id": r.CookieBannerID, "cookie_banner_version_id": r.CookieBannerVersionID, "visitor_id": r.VisitorID, "ip_address": r.IPAddress, "user_agent": r.UserAgent, "consent_data": r.ConsentData, "action": r.Action, "sdk_version": r.SdkVersion, "regulation": r.Regulation, "regulation_source": r.RegulationSource, "country_code": r.CountryCode, "consent_mode": r.ConsentMode, "created_at": r.CreatedAt, } _, err := tx.Exec(ctx, q, args) if err != nil { return fmt.Errorf("cannot insert consent record: %w", err) } return nil } func (r *CookieConsentRecord) LoadByID( ctx context.Context, conn pg.Querier, scope Scoper, id gid.GID, ) error { q := ` SELECT id, organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, ip_address, user_agent, consent_data, action, sdk_version, regulation, regulation_source, country_code, consent_mode, created_at FROM cookie_consent_records WHERE %s AND id = @id ` q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{"id": id} maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { return fmt.Errorf("cannot query consent record: %w", err) } record, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieConsentRecord]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrResourceNotFound } return fmt.Errorf("cannot collect consent record: %w", err) } *r = record return nil } func (r *CookieConsentRecord) LoadLatestByVisitorAndBannerID( ctx context.Context, conn pg.Querier, scope Scoper, cookieBannerID gid.GID, visitorID string, ) error { q := ` SELECT id, organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, ip_address, user_agent, consent_data, action, sdk_version, regulation, regulation_source, country_code, consent_mode, created_at FROM cookie_consent_records WHERE %s AND cookie_banner_id = @cookie_banner_id AND visitor_id = @visitor_id ORDER BY created_at DESC, id DESC LIMIT 1; ` q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ "cookie_banner_id": cookieBannerID, "visitor_id": visitorID, } maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { return fmt.Errorf("cannot query consent records: %w", err) } record, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieConsentRecord]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrResourceNotFound } return fmt.Errorf("cannot collect consent record: %w", err) } *r = record return nil }