Generate tracker policy document from banner snapshot

Add a markdown "Cookie and Tracking Technologies Policy" template in
the policy-writer tone that covers cookies and other tracking
technologies, with GDPR, UK GDPR, FADP, and CCPA/CPRA privacy-rights
framing. Convert it to ProseMirror JSON and add PublishTrackerPolicy,
which builds the document from a banner's latest published version
snapshot and its tracker third parties. The document is GENERATED and
private in the trust center, linked to the banner via
policy_document_id.

Add CookieItem.HumanizedDuration so server-rendered lifetimes match the
banner's humanizeDuration helper, and reword the publish trigger and
backfill comment to reflect the broader tracker scope.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-01 18:20:13 +02:00
parent f6eeeb675f
commit d68df53a6d
6 changed files with 451 additions and 2 deletions

View File

@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -39,6 +40,12 @@ type (
CookieItems []CookieItem
cookieDurationUnit struct {
secs int
name string
snap int
}
CookieCategory struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
@@ -57,6 +64,67 @@ type (
CookieCategories []*CookieCategory
)
// cookieDurationUnits mirrors DURATION_UNITS in
// packages/cookie-banner/src/cookie-utils.ts (and the snap table used by the
// pattern-analysis worker) so durations rendered server-side match exactly what
// visitors see in the consent banner. snap is the per-unit buffer: when the
// remainder is within snap seconds of the next whole unit, round up instead of
// carrying into smaller units.
var cookieDurationUnits = [...]cookieDurationUnit{
{365 * 24 * 3600, "year", 21 * 24 * 3600},
{30 * 24 * 3600, "month", 2 * 24 * 3600},
{7 * 24 * 3600, "week", 12 * 3600},
{24 * 3600, "day", 2 * 3600},
{3600, "hour", 5 * 60},
{60, "minute", 5},
{1, "second", 0},
}
// HumanizedDuration renders the cookie's max-age into a human-readable lifetime
// using the same snapping and composition rules as the banner's
// humanizeDuration helper. A nil or non-positive max-age denotes a session
// cookie that is cleared when the browser closes.
func (c CookieItem) HumanizedDuration() string {
if c.MaxAgeSeconds == nil || *c.MaxAgeSeconds <= 0 {
return "Session"
}
remaining := *c.MaxAgeSeconds
var parts []string
for _, u := range cookieDurationUnits {
if remaining < u.secs-u.snap {
continue
}
count := remaining / u.secs
leftover := remaining - count*u.secs
switch {
case leftover >= u.secs-u.snap:
count++
remaining = 0
case leftover <= u.snap:
remaining = 0
default:
remaining = leftover
}
if count == 1 {
parts = append(parts, fmt.Sprintf("1 %s", u.name))
} else {
parts = append(parts, fmt.Sprintf("%d %ss", count, u.name))
}
}
if len(parts) == 0 {
return "Session"
}
return strings.Join(parts, ", ")
}
func (c CookieItems) MarshalJSON() ([]byte, error) {
if c == nil {
return []byte("[]"), nil

View File

@@ -16,7 +16,7 @@ ALTER TABLE cookie_banners
ADD COLUMN policy_document_id TEXT,
ADD COLUMN policy_generation_requested_at TIMESTAMP WITH TIME ZONE;
-- Backfill: any banner that already has a published version gets its cookie
-- Backfill: any banner that already has a published version gets its tracker
-- policy generated on the worker's first pass.
UPDATE cookie_banners
SET policy_generation_requested_at = NOW()