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:
@@ -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")}>
|
||||||
|
|||||||
@@ -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="
|
data-cookie-consent="
|
||||||
{category.name.toLowerCase()}
|
{category.slug}
|
||||||
"
|
"
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
24
pkg/coredata/migrations/20260423T144457Z.sql
Normal file
24
pkg/coredata/migrations/20260423T144457Z.sql
Normal 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);
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user