diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index fff07e114..616945a15 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -24,14 +24,28 @@ type ( Use pointer types (`*T`) for nullable database columns. +## Denormalized `organization_id` + +Every entity that belongs to an organization carries its own `organization_id` column and Go field — even when the organization can be inferred by walking a foreign key chain. This avoids JOIN queries in `AuthorizationAttributes`, which is called on every authorized request. + +When creating a child entity, copy `OrganizationID` from its parent (e.g. from the banner when creating a category or version). The `AuthorizationAttributes` method then returns the field directly without any database query: + +```go +func (c *CookieCategory) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + return map[string]string{"organization_id": c.OrganizationID.String()}, nil +} +``` + ## Scoper interface `Scoper` provides tenant isolation. Two implementations: -| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case | -|------|-------------|-----------------|-----------------|----------| -| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) | -| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries | + +| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case | +| --------- | --------------------------------------------------- | -------------------------- | ----------------------- | ------------------------------------- | +| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) | +| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries | + Always inject `tenant_id` at INSERT time using `scope.GetTenantID()`, never from the struct. @@ -63,17 +77,19 @@ maps.Copy(args, cursor.SQLArguments()) ## Standard method signatures -| Method | Receiver | Returns | Purpose | -|--------|----------|---------|---------| -| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID | -| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key | -| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list | -| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows | -| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` | -| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` | -| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity | -| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination | -| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation | + +| Method | Receiver | Returns | Purpose | +| -------------------------------------------------------- | ----------- | ---------------------------- | ------------------------------------ | +| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID | +| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key | +| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list | +| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows | +| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` | +| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` | +| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity | +| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination | +| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation | + ## Row collection @@ -171,3 +187,4 @@ Files in `pkg/coredata/migrations/` use timestamp naming: `YYYYMMDDTHHMMSSZ.sql` 3. **Order field file** (`entity_order_field.go`) — order field type, constants, `Column`, `IsValid`, marshaling 4. **Entity type constant** — add to `entity_type_reg.go` and `NewEntityFromID` 5. **Migration** — `YYYYMMDDTHHMMSSZ.sql` with CREATE TABLE + diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 4fe9c0519..ca3b41a33 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -206,6 +206,7 @@ func (s *Service) ensureDraftVersion( now := time.Now() version := &coredata.CookieBannerVersion{ ID: gid.New(scope.GetTenantID(), coredata.CookieBannerVersionEntityType), + OrganizationID: banner.OrganizationID, CookieBannerID: banner.ID, State: coredata.CookieBannerVersionStateDraft, CreatedAt: now, @@ -265,6 +266,7 @@ func (s *Service) CreateCookieBanner( for _, dc := range defaultCategories { category := &coredata.CookieCategory{ ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType), + OrganizationID: banner.OrganizationID, CookieBannerID: banner.ID, Name: dc.Name, Description: dc.Description, @@ -591,6 +593,14 @@ func (s *Service) CreateCookieCategory( err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + return fmt.Errorf("cannot load cookie banner: %w", err) + } + now := time.Now() cookies := req.Cookies @@ -600,6 +610,7 @@ func (s *Service) CreateCookieCategory( category = &coredata.CookieCategory{ ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType), + OrganizationID: banner.OrganizationID, CookieBannerID: req.CookieBannerID, Name: req.Name, Description: req.Description, @@ -614,11 +625,6 @@ func (s *Service) CreateCookieCategory( return fmt.Errorf("cannot insert cookie category: %w", err) } - var banner coredata.CookieBanner - if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil { - return fmt.Errorf("cannot load cookie banner: %w", err) - } - var categories coredata.CookieCategories if err := categories.LoadAllByCookieBannerID(ctx, tx, req.CookieBannerID); err != nil { return fmt.Errorf("cannot load cookie categories: %w", err) @@ -934,15 +940,16 @@ func (s *Service) CreateCookieConsentRecord( } record = &coredata.CookieConsentRecord{ - ID: gid.New(scope.GetTenantID(), coredata.CookieConsentRecordEntityType), - CookieBannerID: req.CookieBannerID, - CookieBannerVersionID: publishedVersion.ID, - VisitorID: req.VisitorID, - IPAddress: req.IPAddress, - UserAgent: req.UserAgent, - ConsentData: req.ConsentData, - Action: req.Action, - CreatedAt: time.Now(), + ID: gid.New(scope.GetTenantID(), coredata.CookieConsentRecordEntityType), + OrganizationID: publishedVersion.OrganizationID, + CookieBannerID: req.CookieBannerID, + CookieBannerVersionID: publishedVersion.ID, + VisitorID: req.VisitorID, + IPAddress: req.IPAddress, + UserAgent: req.UserAgent, + ConsentData: req.ConsentData, + Action: req.Action, + CreatedAt: time.Now(), } if err := record.Insert(ctx, tx, scope); err != nil { diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go index 91ebbff34..411bc90ea 100644 --- a/pkg/coredata/cookie_banner_version.go +++ b/pkg/coredata/cookie_banner_version.go @@ -45,6 +45,7 @@ type ( CookieBannerVersion struct { ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` CookieBannerID gid.GID `db:"cookie_banner_id"` Version int `db:"version"` State CookieBannerVersionState `db:"state"` @@ -66,23 +67,7 @@ func (v *CookieBannerVersion) CursorKey(field CookieBannerVersionOrderField) pag } func (v *CookieBannerVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := ` -SELECT cb.organization_id -FROM cookie_banner_versions cbv -JOIN cookie_banners cb ON cbv.cookie_banner_id = cb.id -WHERE cbv.id = $1 -LIMIT 1; -` - - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - return nil, fmt.Errorf("cannot query cookie banner version authorization attributes: %w", err) - } - - return map[string]string{"organization_id": organizationID.String()}, nil + return map[string]string{"organization_id": v.OrganizationID.String()}, nil } func (v *CookieBannerVersion) GetSnapshot() (CookieBannerVersionSnapshot, error) { @@ -111,6 +96,7 @@ func (v *CookieBannerVersion) LoadByID( q := ` SELECT id, + organization_id, cookie_banner_id, version, state, @@ -158,6 +144,7 @@ func (v *CookieBannerVersions) LoadByCookieBannerID( q := ` SELECT id, + organization_id, cookie_banner_id, version, state, @@ -234,6 +221,7 @@ func (v *CookieBannerVersion) LoadByCookieBannerIDAndVersion( q := ` SELECT id, + organization_id, cookie_banner_id, version, state, @@ -284,6 +272,7 @@ func (v *CookieBannerVersion) LoadLatestByCookieBannerID( q := ` SELECT id, + organization_id, cookie_banner_id, version, state, @@ -362,6 +351,7 @@ func (v *CookieBannerVersion) Insert( INSERT INTO cookie_banner_versions ( id, tenant_id, + organization_id, cookie_banner_id, version, state, @@ -371,6 +361,7 @@ INSERT INTO cookie_banner_versions ( ) VALUES ( @id, @tenant_id, + @organization_id, @cookie_banner_id, @version, @state, @@ -383,6 +374,7 @@ INSERT INTO cookie_banner_versions ( args := pgx.StrictNamedArgs{ "id": v.ID, "tenant_id": scope.GetTenantID(), + "organization_id": v.OrganizationID, "cookie_banner_id": v.CookieBannerID, "version": v.Version, "state": v.State, diff --git a/pkg/coredata/cookie_category.go b/pkg/coredata/cookie_category.go index c4ff4d1be..e266d221b 100644 --- a/pkg/coredata/cookie_category.go +++ b/pkg/coredata/cookie_category.go @@ -39,6 +39,7 @@ type ( CookieCategory struct { ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` CookieBannerID gid.GID `db:"cookie_banner_id"` Name string `db:"name"` Description string `db:"description"` @@ -77,23 +78,7 @@ func (c *CookieCategory) CursorKey(field CookieCategoryOrderField) page.CursorKe } 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 + return map[string]string{"organization_id": c.OrganizationID.String()}, nil } func (c *CookieCategory) LoadByID( @@ -105,6 +90,7 @@ func (c *CookieCategory) LoadByID( q := ` SELECT id, + organization_id, cookie_banner_id, name, description, @@ -154,6 +140,7 @@ func (c *CookieCategories) LoadByCookieBannerID( q := ` SELECT id, + organization_id, cookie_banner_id, name, description, @@ -230,6 +217,7 @@ func (c *CookieCategories) LoadAllByCookieBannerID( q := ` SELECT id, + organization_id, cookie_banner_id, name, description, @@ -272,6 +260,7 @@ func (c *CookieCategory) Insert( INSERT INTO cookie_categories ( id, tenant_id, + organization_id, cookie_banner_id, name, description, @@ -283,6 +272,7 @@ INSERT INTO cookie_categories ( ) VALUES ( @id, @tenant_id, + @organization_id, @cookie_banner_id, @name, @description, @@ -297,6 +287,7 @@ INSERT INTO cookie_categories ( args := pgx.StrictNamedArgs{ "id": c.ID, "tenant_id": scope.GetTenantID(), + "organization_id": c.OrganizationID, "cookie_banner_id": c.CookieBannerID, "name": c.Name, "description": c.Description, @@ -333,6 +324,7 @@ WHERE AND id = @id RETURNING id, + organization_id, cookie_banner_id, name, description, diff --git a/pkg/coredata/cookie_consent_record.go b/pkg/coredata/cookie_consent_record.go index 7fa17e9bf..1cb3fcf9b 100644 --- a/pkg/coredata/cookie_consent_record.go +++ b/pkg/coredata/cookie_consent_record.go @@ -17,7 +17,6 @@ package coredata import ( "context" "encoding/json" - "errors" "fmt" "maps" "time" @@ -31,6 +30,7 @@ import ( 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"` @@ -54,23 +54,7 @@ func (r *CookieConsentRecord) CursorKey(field CookieConsentRecordOrderField) pag } func (r *CookieConsentRecord) 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 + return map[string]string{"organization_id": r.OrganizationID.String()}, nil } func (r *CookieConsentRecords) LoadByCookieBannerID( @@ -84,6 +68,7 @@ func (r *CookieConsentRecords) LoadByCookieBannerID( q := ` SELECT id, + organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, @@ -166,6 +151,7 @@ func (r *CookieConsentRecord) Insert( INSERT INTO cookie_consent_records ( id, tenant_id, + organization_id, cookie_banner_id, cookie_banner_version_id, visitor_id, @@ -177,6 +163,7 @@ INSERT INTO cookie_consent_records ( ) VALUES ( @id, @tenant_id, + @organization_id, @cookie_banner_id, @cookie_banner_version_id, @visitor_id, @@ -191,6 +178,7 @@ INSERT INTO cookie_consent_records ( 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, diff --git a/pkg/coredata/migrations/20260413T120000Z.sql b/pkg/coredata/migrations/20260413T120000Z.sql index e2d930ccf..4989b1b6f 100644 --- a/pkg/coredata/migrations/20260413T120000Z.sql +++ b/pkg/coredata/migrations/20260413T120000Z.sql @@ -31,6 +31,7 @@ CREATE TYPE cookie_banner_version_state AS ENUM ('DRAFT', 'PUBLISHED'); CREATE TABLE cookie_banner_versions ( id TEXT 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, version INTEGER NOT NULL, state cookie_banner_version_state NOT NULL, @@ -42,6 +43,10 @@ CREATE TABLE cookie_banner_versions ( UNIQUE (cookie_banner_id, version) ); --- Add version reference to consent records +-- Denormalize organization_id onto categories and consent records +ALTER TABLE cookie_categories + ADD COLUMN organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE cookie_consent_records + ADD COLUMN organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, ADD COLUMN cookie_banner_version_id TEXT NOT NULL REFERENCES cookie_banner_versions(id);