Add slug to cookie categories for stable consent identifiers

The category slug provides a stable, URL-safe key used as the
data-cookie-consent attribute value and consent data key, replacing
the fragile category name. This prevents breakage when categories
are renamed.

- Add slug column with unique-per-banner constraint and backfill migration
- Add Slug validator (lowercase alphanumeric + hyphens)
- Propagate slug through GraphQL schema, service layer, and snapshot
- Update console UI with slug field in create/edit forms
- Switch cookie-banner widget to use slug as consent data keys

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 19:07:11 +04:00
parent bb39daebc5
commit 165b9ad9d3
19 changed files with 218 additions and 22 deletions

View File

@@ -40,6 +40,7 @@ const createMutation = graphql`
node { node {
id id
name name
slug
description description
kind kind
rank rank
@@ -78,8 +79,27 @@ export function CategoryDialog({
const [create, isCreating] = useMutation<CategoryDialogCreateMutation>(createMutation); const [create, isCreating] = useMutation<CategoryDialogCreateMutation>(createMutation);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched) {
setSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
);
}
};
const handleSlugChange = (value: string) => {
setSlugTouched(true);
setSlug(value);
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -88,6 +108,7 @@ export function CategoryDialog({
input: { input: {
cookieBannerId, cookieBannerId,
name, name,
slug,
description, description,
rank: nextRank, rank: nextRank,
}, },
@@ -113,7 +134,11 @@ export function CategoryDialog({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<DialogContent padded className="space-y-4"> <DialogContent padded className="space-y-4">
<Field label={__("Name")}> <Field label={__("Name")}>
<Input value={name} onChange={e => setName(e.target.value)} required /> <Input value={name} onChange={e => handleNameChange(e.target.value)} required />
</Field>
<Field label={__("Slug")} help={__("Used as the data-cookie-consent attribute value")}>
<Input value={slug} onChange={e => handleSlugChange(e.target.value)} required pattern="[a-z0-9]+(-[a-z0-9]+)*" />
</Field> </Field>
<Field label={__("Description")}> <Field label={__("Description")}>

View File

@@ -56,6 +56,7 @@ export const categorySectionFragment = graphql`
fragment CategorySectionFragment on CookieCategory { fragment CategorySectionFragment on CookieCategory {
id id
name name
slug
description description
kind kind
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@@ -93,6 +94,7 @@ const updateCategoryMutation = graphql`
cookieCategory { cookieCategory {
id id
name name
slug
description description
rank rank
updatedAt updatedAt
@@ -235,12 +237,13 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const cookies = category.cookies.edges.map(e => e.node); const cookies = category.cookies.edges.map(e => e.node);
const isMutating = isUpdating || isCreating || isUpdatingCookie; const isMutating = isUpdating || isCreating || isUpdatingCookie;
const handleSaveCategory = (name: string, description: string) => { const handleSaveCategory = (name: string, slug: string, description: string) => {
updateCategory({ updateCategory({
variables: { variables: {
input: { input: {
cookieCategoryId: category.id, cookieCategoryId: category.id,
name, name,
slug,
description, description,
}, },
}, },
@@ -476,6 +479,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
? ( ? (
<EditCategoryForm <EditCategoryForm
name={category.name} name={category.name}
slug={category.slug}
description={category.description} description={category.description}
isUpdating={isUpdating} isUpdating={isUpdating}
onSave={handleSaveCategory} onSave={handleSaveCategory}
@@ -517,7 +521,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
{" "} {" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]"> <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
data-cookie-consent=&quot; data-cookie-consent=&quot;
{category.name.toLowerCase()} {category.slug}
&quot; &quot;
</code> </code>
</p> </p>

View File

@@ -18,14 +18,16 @@ import { useState } from "react";
interface EditCategoryFormProps { interface EditCategoryFormProps {
name: string; name: string;
slug: string;
description: string; description: string;
isUpdating: boolean; isUpdating: boolean;
onSave: (name: string, description: string) => void; onSave: (name: string, slug: string, description: string) => void;
onCancel: () => void; onCancel: () => void;
} }
export function EditCategoryForm({ export function EditCategoryForm({
name, name,
slug,
description, description,
isUpdating, isUpdating,
onSave, onSave,
@@ -33,6 +35,7 @@ export function EditCategoryForm({
}: EditCategoryFormProps) { }: EditCategoryFormProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const [editName, setEditName] = useState(name); const [editName, setEditName] = useState(name);
const [editSlug, setEditSlug] = useState(slug);
const [editDescription, setEditDescription] = useState(description); const [editDescription, setEditDescription] = useState(description);
return ( return (
@@ -42,6 +45,12 @@ export function EditCategoryForm({
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
placeholder={__("Category name")} placeholder={__("Category name")}
/> />
<Input
value={editSlug}
onChange={e => setEditSlug(e.target.value)}
placeholder={__("Category slug")}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
/>
<Textarea <Textarea
value={editDescription} value={editDescription}
onChange={e => setEditDescription(e.target.value)} onChange={e => setEditDescription(e.target.value)}
@@ -50,7 +59,7 @@ export function EditCategoryForm({
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={() => onSave(editName, editDescription)} onClick={() => onSave(editName, editSlug, editDescription)}
disabled={isUpdating} disabled={isUpdating}
> >
{isUpdating ? __("Saving...") : __("Save")} {isUpdating ? __("Saving...") : __("Save")}

View File

@@ -35,6 +35,7 @@ export interface CookieItem {
export interface Category { export interface Category {
name: string; name: string;
slug: string;
description: string; description: string;
kind: string; kind: string;
cookies: CookieItem[]; cookies: CookieItem[];
@@ -174,7 +175,7 @@ export class CookieBannerClient {
const consentData: Record<string, boolean> = {}; const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) { for (const cat of cfg.categories) {
consentData[cat.name] = true; consentData[cat.slug] = true;
} }
this.recordConsent("ACCEPT_ALL", consentData); this.recordConsent("ACCEPT_ALL", consentData);
@@ -185,7 +186,7 @@ export class CookieBannerClient {
const consentData: Record<string, boolean> = {}; const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) { for (const cat of cfg.categories) {
consentData[cat.name] = cat.kind === "NECESSARY"; consentData[cat.slug] = cat.kind === "NECESSARY";
} }
this.recordConsent("REJECT_ALL", consentData); this.recordConsent("REJECT_ALL", consentData);
@@ -196,7 +197,7 @@ export class CookieBannerClient {
const consentData: Record<string, boolean> = {}; const consentData: Record<string, boolean> = {};
for (const cat of cfg.categories) { for (const cat of cfg.categories) {
consentData[cat.name] = cat.kind === "NECESSARY" || !!categories[cat.name]; consentData[cat.slug] = cat.kind === "NECESSARY" || !!categories[cat.slug];
} }
this.recordConsent("CUSTOMIZE", consentData); this.recordConsent("CUSTOMIZE", consentData);
@@ -244,8 +245,8 @@ export class CookieBannerClient {
const categoryCookies: Record<string, string[]> = {}; const categoryCookies: Record<string, string[]> = {};
const categoryLabels: Record<string, string> = {}; const categoryLabels: Record<string, string> = {};
for (const cat of this.config.categories) { for (const cat of this.config.categories) {
categoryCookies[cat.name] = cat.cookies.map((c) => c.name); categoryCookies[cat.slug] = cat.cookies.map((c) => c.name);
categoryLabels[cat.name] = cat.name; categoryLabels[cat.slug] = cat.name;
} }
const texts = this.config.texts; const texts = this.config.texts;

View File

@@ -59,6 +59,7 @@ export class ProboCategoryList extends ProboElement {
const wrapper = document.createElement("probo-category"); const wrapper = document.createElement("probo-category");
wrapper.setAttribute("name", cat.name); wrapper.setAttribute("name", cat.name);
wrapper.setAttribute("slug", cat.slug);
wrapper.setAttribute("kind", cat.kind); wrapper.setAttribute("kind", cat.kind);
wrapper.setAttribute("description", cat.description); wrapper.setAttribute("description", cat.description);
wrapper.setAttribute("cookies", JSON.stringify(cat.cookies)); wrapper.setAttribute("cookies", JSON.stringify(cat.cookies));

View File

@@ -49,6 +49,7 @@ export class ProboCategoryToggle extends ProboElement {
if (!this.category || !this.root) return; if (!this.category || !this.root) return;
const name = this.category.categoryName; const name = this.category.categoryName;
const slug = this.category.categorySlug;
this.checkbox.setAttribute("aria-label", name); this.checkbox.setAttribute("aria-label", name);
const isRequired = this.category.kind === "NECESSARY"; const isRequired = this.category.kind === "NECESSARY";
@@ -59,14 +60,14 @@ export class ProboCategoryToggle extends ProboElement {
} }
const draft = this.root.consentDraft; const draft = this.root.consentDraft;
this.checkbox.checked = !!draft[name]; this.checkbox.checked = !!draft[slug];
this.checkbox.addEventListener("change", this.handleChange); this.checkbox.addEventListener("change", this.handleChange);
if (this.root) { if (this.root) {
this.root.addEventListener("probo-state", (e: Event) => { this.root.addEventListener("probo-state", (e: Event) => {
const { state } = (e as CustomEvent).detail; const { state } = (e as CustomEvent).detail;
if (state === "panel" && this.checkbox && this.category && this.root) { if (state === "panel" && this.checkbox && this.category && this.root) {
this.checkbox.checked = !!this.root.consentDraft[this.category.categoryName]; this.checkbox.checked = !!this.root.consentDraft[this.category.categorySlug];
} }
}); });
} }
@@ -74,6 +75,6 @@ export class ProboCategoryToggle extends ProboElement {
private handleChange = (): void => { private handleChange = (): void => {
if (!this.checkbox || !this.category || !this.root) return; if (!this.checkbox || !this.category || !this.root) return;
this.root.updateDraft(this.category.categoryName, this.checkbox.checked); this.root.updateDraft(this.category.categorySlug, this.checkbox.checked);
}; };
} }

View File

@@ -21,6 +21,10 @@ export class ProboCategory extends ProboElement {
return this.getAttribute("name") ?? "Other"; return this.getAttribute("name") ?? "Other";
} }
get categorySlug(): string {
return this.getAttribute("slug") ?? this.categoryName.toLowerCase();
}
get kind(): string { get kind(): string {
return this.getAttribute("kind") ?? "NORMAL"; return this.getAttribute("kind") ?? "NORMAL";
} }

View File

@@ -109,11 +109,11 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
for (const cat of config.categories) { for (const cat of config.categories) {
if (cat.kind === "NECESSARY") { if (cat.kind === "NECESSARY") {
draft[cat.name] = true; draft[cat.slug] = true;
} else if (existing && cat.name in existing) { } else if (existing && cat.slug in existing) {
draft[cat.name] = existing[cat.name]; draft[cat.slug] = existing[cat.slug];
} else { } else {
draft[cat.name] = config.consent_mode === "OPT_OUT"; draft[cat.slug] = config.consent_mode === "OPT_OUT";
} }
} }

View File

@@ -18,15 +18,16 @@ import "go.probo.inc/probo/pkg/coredata"
var defaultCategories = []struct { var defaultCategories = []struct {
Name string Name string
Slug string
Description string Description string
Kind coredata.CookieCategoryKind Kind coredata.CookieCategoryKind
Rank int Rank int
}{ }{
{"Necessary", "Essential cookies required for the website to function properly.", coredata.CookieCategoryKindNecessary, 0}, {"Necessary", "necessary", "Essential cookies required for the website to function properly.", coredata.CookieCategoryKindNecessary, 0},
{"Analytics", "Cookies that help understand how visitors interact with the website.", coredata.CookieCategoryKindNormal, 1}, {"Analytics", "analytics", "Cookies that help understand how visitors interact with the website.", coredata.CookieCategoryKindNormal, 1},
{"Advertising", "Cookies used to deliver relevant advertisements and track campaigns.", coredata.CookieCategoryKindNormal, 2}, {"Advertising", "advertising", "Cookies used to deliver relevant advertisements and track campaigns.", coredata.CookieCategoryKindNormal, 2},
{"Functional", "Cookies that enable enhanced functionality and personalization.", coredata.CookieCategoryKindNormal, 3}, {"Functional", "functional", "Cookies that enable enhanced functionality and personalization.", coredata.CookieCategoryKindNormal, 3},
{"Uncategorised", "Cookies that have not been assigned to a category yet.", coredata.CookieCategoryKindUncategorised, 4}, {"Uncategorised", "uncategorised", "Cookies that have not been assigned to a category yet.", coredata.CookieCategoryKindUncategorised, 4},
} }
var defaultUIStringsByLanguage = map[string]map[string]string{ var defaultUIStringsByLanguage = map[string]map[string]string{

View File

@@ -26,6 +26,7 @@ var (
ErrNoPublishedVersion = errors.New("no published cookie banner version") ErrNoPublishedVersion = errors.New("no published cookie banner version")
ErrNoDraftVersion = errors.New("no draft cookie banner version to publish") ErrNoDraftVersion = errors.New("no draft cookie banner version to publish")
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category") ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
ErrCategorySlugAlreadyExists = errors.New("a category with this slug already exists in this banner")
ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner") ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner")
ErrConsentNotFound = errors.New("consent record not found") ErrConsentNotFound = errors.New("consent record not found")
ErrCookieNotFound = errors.New("cookie not found") ErrCookieNotFound = errors.New("cookie not found")

View File

@@ -53,6 +53,7 @@ type (
CreateCookieCategoryRequest struct { CreateCookieCategoryRequest struct {
CookieBannerID gid.GID CookieBannerID gid.GID
Name string Name string
Slug string
Description string Description string
Rank int Rank int
} }
@@ -70,6 +71,7 @@ type (
UpdateCookieCategoryRequest struct { UpdateCookieCategoryRequest struct {
CookieCategoryID gid.GID CookieCategoryID gid.GID
Name *string Name *string
Slug *string
Description *string Description *string
} }
@@ -189,6 +191,7 @@ func (r *CreateCookieCategoryRequest) Validate() error {
v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType)) v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType))
v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255)) v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Slug, "slug", validator.Required(), validator.Slug(100))
v.Check(r.Description, "description", validator.Required(), validator.SafeText(1000)) v.Check(r.Description, "description", validator.Required(), validator.SafeText(1000))
v.Check(r.Rank, "rank", validator.Min(0)) v.Check(r.Rank, "rank", validator.Min(0))
@@ -200,6 +203,7 @@ func (r *UpdateCookieCategoryRequest) Validate() error {
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(255)) v.Check(r.Name, "name", validator.SafeTextNoNewLine(255))
v.Check(r.Slug, "slug", validator.Slug(100))
v.Check(r.Description, "description", validator.SafeText(1000)) v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error() return v.Error()
@@ -327,6 +331,7 @@ func buildSnapshot(
} }
snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{ snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{
Name: c.Name, Name: c.Name,
Slug: c.Slug,
Description: c.Description, Description: c.Description,
Kind: c.Kind, Kind: c.Kind,
Cookies: cookies, Cookies: cookies,
@@ -532,6 +537,7 @@ func (s *Service) CreateCookieBanner(
OrganizationID: banner.OrganizationID, OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID, CookieBannerID: banner.ID,
Name: dc.Name, Name: dc.Name,
Slug: dc.Slug,
Description: dc.Description, Description: dc.Description,
Kind: dc.Kind, Kind: dc.Kind,
Rank: dc.Rank, Rank: dc.Rank,
@@ -925,6 +931,7 @@ func (s *Service) CreateCookieCategory(
OrganizationID: banner.OrganizationID, OrganizationID: banner.OrganizationID,
CookieBannerID: req.CookieBannerID, CookieBannerID: req.CookieBannerID,
Name: req.Name, Name: req.Name,
Slug: req.Slug,
Description: req.Description, Description: req.Description,
Kind: coredata.CookieCategoryKindNormal, Kind: coredata.CookieCategoryKindNormal,
Rank: req.Rank, Rank: req.Rank,
@@ -933,6 +940,9 @@ func (s *Service) CreateCookieCategory(
} }
if err := category.Insert(ctx, tx, scope); err != nil { if err := category.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCategorySlugAlreadyExists
}
return fmt.Errorf("cannot insert cookie category: %w", err) return fmt.Errorf("cannot insert cookie category: %w", err)
} }
@@ -1274,6 +1284,9 @@ func (s *Service) UpdateCookieCategory(
if req.Name != nil { if req.Name != nil {
category.Name = *req.Name category.Name = *req.Name
} }
if req.Slug != nil {
category.Slug = *req.Slug
}
if req.Description != nil { if req.Description != nil {
category.Description = *req.Description category.Description = *req.Description
} }
@@ -1281,6 +1294,9 @@ func (s *Service) UpdateCookieCategory(
category.UpdatedAt = time.Now() category.UpdatedAt = time.Now()
if err := category.Update(ctx, tx, scope); err != nil { if err := category.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCategorySlugAlreadyExists
}
return fmt.Errorf("cannot update cookie category: %w", err) return fmt.Errorf("cannot update cookie category: %w", err)
} }

View File

@@ -50,6 +50,7 @@ type (
CookieBannerVersionSnapshotCategory struct { CookieBannerVersionSnapshotCategory struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Kind CookieCategoryKind `json:"kind"` Kind CookieCategoryKind `json:"kind"`
Cookies CookieItems `json:"cookies"` Cookies CookieItems `json:"cookies"`

View File

@@ -23,6 +23,7 @@ import (
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
@@ -42,6 +43,7 @@ type (
OrganizationID gid.GID `db:"organization_id"` OrganizationID gid.GID `db:"organization_id"`
CookieBannerID gid.GID `db:"cookie_banner_id"` CookieBannerID gid.GID `db:"cookie_banner_id"`
Name string `db:"name"` Name string `db:"name"`
Slug string `db:"slug"`
Description string `db:"description"` Description string `db:"description"`
Kind CookieCategoryKind `db:"kind"` Kind CookieCategoryKind `db:"kind"`
Rank int `db:"rank"` Rank int `db:"rank"`
@@ -103,6 +105,7 @@ SELECT
organization_id, organization_id,
cookie_banner_id, cookie_banner_id,
name, name,
slug,
description, description,
kind, kind,
rank, rank,
@@ -152,6 +155,7 @@ SELECT
organization_id, organization_id,
cookie_banner_id, cookie_banner_id,
name, name,
slug,
description, description,
kind, kind,
rank, rank,
@@ -229,6 +233,7 @@ SELECT
organization_id, organization_id,
cookie_banner_id, cookie_banner_id,
name, name,
slug,
description, description,
kind, kind,
rank, rank,
@@ -275,6 +280,7 @@ INSERT INTO cookie_categories (
organization_id, organization_id,
cookie_banner_id, cookie_banner_id,
name, name,
slug,
description, description,
kind, kind,
rank, rank,
@@ -286,6 +292,7 @@ INSERT INTO cookie_categories (
@organization_id, @organization_id,
@cookie_banner_id, @cookie_banner_id,
@name, @name,
@slug,
@description, @description,
@kind, @kind,
@rank, @rank,
@@ -300,6 +307,7 @@ INSERT INTO cookie_categories (
"organization_id": c.OrganizationID, "organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID, "cookie_banner_id": c.CookieBannerID,
"name": c.Name, "name": c.Name,
"slug": c.Slug,
"description": c.Description, "description": c.Description,
"kind": c.Kind, "kind": c.Kind,
"rank": c.Rank, "rank": c.Rank,
@@ -309,6 +317,11 @@ INSERT INTO cookie_categories (
_, err := tx.Exec(ctx, q, args) _, err := tx.Exec(ctx, q, args)
if err != nil { if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_categories_unique_slug_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert cookie category: %w", err) return fmt.Errorf("cannot insert cookie category: %w", err)
} }
@@ -324,6 +337,7 @@ func (c *CookieCategory) Update(
UPDATE cookie_categories UPDATE cookie_categories
SET SET
name = @name, name = @name,
slug = @slug,
description = @description, description = @description,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
@@ -336,6 +350,7 @@ WHERE
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": c.ID, "id": c.ID,
"name": c.Name, "name": c.Name,
"slug": c.Slug,
"description": c.Description, "description": c.Description,
"updated_at": c.UpdatedAt, "updated_at": c.UpdatedAt,
} }
@@ -343,6 +358,11 @@ WHERE
result, err := tx.Exec(ctx, q, args) result, err := tx.Exec(ctx, q, args)
if err != nil { if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_categories_unique_slug_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update cookie category: %w", err) return fmt.Errorf("cannot update cookie category: %w", err)
} }
@@ -443,6 +463,7 @@ SELECT
organization_id, organization_id,
cookie_banner_id, cookie_banner_id,
name, name,
slug,
description, description,
kind, kind,
rank, rank,

View File

@@ -0,0 +1,24 @@
-- Copyright (c) 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.
ALTER TABLE cookie_categories
ADD COLUMN slug TEXT NOT NULL DEFAULT '';
UPDATE cookie_categories
SET slug = LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9]+', '-', 'g'), '^-|-$', '', 'g'));
ALTER TABLE cookie_categories ALTER COLUMN slug DROP DEFAULT;
CREATE UNIQUE INDEX idx_cookie_categories_unique_slug_per_banner
ON cookie_categories (cookie_banner_id, slug);

View File

@@ -419,6 +419,7 @@ func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types
cookiebanner.CreateCookieCategoryRequest{ cookiebanner.CreateCookieCategoryRequest{
CookieBannerID: input.CookieBannerID, CookieBannerID: input.CookieBannerID,
Name: input.Name, Name: input.Name,
Slug: input.Slug,
Description: input.Description, Description: input.Description,
Rank: input.Rank, Rank: input.Rank,
}, },
@@ -427,6 +428,9 @@ func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types
if errors.Is(err, cookiebanner.ErrBannerNotFound) { if errors.Is(err, cookiebanner.ErrBannerNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
if errors.Is(err, cookiebanner.ErrCategorySlugAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
} }
@@ -460,6 +464,7 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
cookiebanner.UpdateCookieCategoryRequest{ cookiebanner.UpdateCookieCategoryRequest{
CookieCategoryID: input.CookieCategoryID, CookieCategoryID: input.CookieCategoryID,
Name: input.Name, Name: input.Name,
Slug: input.Slug,
Description: input.Description, Description: input.Description,
}, },
) )
@@ -467,6 +472,9 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
if errors.Is(err, cookiebanner.ErrCategoryNotFound) { if errors.Is(err, cookiebanner.ErrCategoryNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
if errors.Is(err, cookiebanner.ErrCategorySlugAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
} }

View File

@@ -119,6 +119,7 @@ type CookieCategory implements Node {
id: ID! id: ID!
cookieBanner: CookieBanner @goField(forceResolver: true) cookieBanner: CookieBanner @goField(forceResolver: true)
name: String! name: String!
slug: String!
description: String! description: String!
kind: CookieCategoryKind! kind: CookieCategoryKind!
rank: Int! rank: Int!
@@ -300,6 +301,7 @@ input PublishCookieBannerVersionInput {
input CreateCookieCategoryInput { input CreateCookieCategoryInput {
cookieBannerId: ID! cookieBannerId: ID!
name: String! name: String!
slug: String!
description: String! description: String!
rank: Int! rank: Int!
} }
@@ -307,6 +309,7 @@ input CreateCookieCategoryInput {
input UpdateCookieCategoryInput { input UpdateCookieCategoryInput {
cookieCategoryId: ID! cookieCategoryId: ID!
name: String name: String
slug: String
description: String description: String
} }

View File

@@ -67,6 +67,7 @@ func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
ID: c.CookieBannerID, ID: c.CookieBannerID,
}, },
Name: c.Name, Name: c.Name,
Slug: c.Slug,
Description: c.Description, Description: c.Description,
Kind: c.Kind, Kind: c.Kind,
Rank: c.Rank, Rank: c.Rank,

View File

@@ -15,6 +15,7 @@
package validator package validator
import ( import (
"fmt"
"net/url" "net/url"
"regexp" "regexp"
"slices" "slices"
@@ -25,6 +26,7 @@ import (
var ( var (
domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
) )
// URL validates that a string is a valid URL with http or https scheme. // URL validates that a string is a valid URL with http or https scheme.
@@ -177,6 +179,36 @@ func Origin() ValidatorFunc {
} }
} }
// Slug validates that a string is a lowercase alphanumeric slug (with hyphens, no
// leading/trailing hyphens, no consecutive hyphens) and does not exceed maxLen.
func Slug(maxLen int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if len(str) > maxLen {
return newValidationError(ErrorCodeTooLong, fmt.Sprintf("slug must be at most %d characters", maxLen))
}
if !slugRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "slug must contain only lowercase letters, numbers, and hyphens")
}
return nil
}
}
// Domain validates that a string is a valid domain name. // Domain validates that a string is a valid domain name.
func Domain() ValidatorFunc { func Domain() ValidatorFunc {
return func(value any) *ValidationError { return func(value any) *ValidationError {

View File

@@ -164,6 +164,49 @@ func TestOrigin(t *testing.T) {
} }
} }
func TestSlug(t *testing.T) {
tests := []struct {
name string
value any
maxLen int
wantError bool
wantCode ErrorCode
}{
{"valid simple slug", "analytics", 100, false, ""},
{"valid with hyphens", "my-category", 100, false, ""},
{"valid multi-segment", "my-cool-category", 100, false, ""},
{"valid single char", "a", 100, false, ""},
{"valid digits only", "123", 100, false, ""},
{"valid mixed", "cat2", 100, false, ""},
{"valid digit-hyphen-alpha", "1-a", 100, false, ""},
{"invalid - uppercase", "Analytics", 100, true, ErrorCodeInvalidFormat},
{"invalid - leading hyphen", "-analytics", 100, true, ErrorCodeInvalidFormat},
{"invalid - trailing hyphen", "analytics-", 100, true, ErrorCodeInvalidFormat},
{"invalid - consecutive hyphens", "my--category", 100, true, ErrorCodeInvalidFormat},
{"invalid - underscore", "my_category", 100, true, ErrorCodeInvalidFormat},
{"invalid - spaces", "my category", 100, true, ErrorCodeInvalidFormat},
{"invalid - special chars", "my@category", 100, true, ErrorCodeInvalidFormat},
{"invalid - dot", "my.category", 100, true, ErrorCodeInvalidFormat},
{"too long", "abcdefghijk", 10, true, ErrorCodeTooLong},
{"exactly max length", "abcdefghij", 10, false, ""},
{"empty string", "", 100, false, ""},
{"nil pointer", (*string)(nil), 100, false, ""},
{"non-string", 123, 100, true, ErrorCodeInvalidFormat},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Slug(tt.maxLen)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Slug(%d) error = %v, wantError %v", tt.maxLen, err, tt.wantError)
}
if err != nil && tt.wantCode != "" && err.Code != tt.wantCode {
t.Errorf("Expected error code %s, got %s", tt.wantCode, err.Code)
}
})
}
}
func TestDomain(t *testing.T) { func TestDomain(t *testing.T) {
t.Run("valid domain", func(t *testing.T) { t.Run("valid domain", func(t *testing.T) {
str := "example.com" str := "example.com"