Create a db table for cookies for easiest management

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-21 13:25:43 +04:00
parent f743c743c1
commit 29c0d55e75
18 changed files with 1485 additions and 346 deletions

View File

@@ -43,11 +43,6 @@ const createMutation = graphql`
description description
kind kind
rank rank
cookies {
name
duration
description
}
createdAt createdAt
updatedAt updatedAt
} }

View File

@@ -31,22 +31,18 @@ import { useState } from "react";
import { useMutation } from "react-relay"; import { useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { CookieDialogUpdateMutation } from "#/__generated__/core/CookieDialogUpdateMutation.graphql"; import type { CookieDialogCreateMutation } from "#/__generated__/core/CookieDialogCreateMutation.graphql";
const updateCategoryMutation = graphql` const createCookieMutation = graphql`
mutation CookieDialogUpdateMutation($input: UpdateCookieCategoryInput!) { mutation CookieDialogCreateMutation($input: CreateCookieInput!) {
updateCookieCategory(input: $input) { createCookie(input: $input) {
cookieCategory { cookieEdge {
id node {
name id
description
rank
cookies {
name name
duration duration
description description
} }
updatedAt
} }
cookieBanner { cookieBanner {
id id
@@ -60,16 +56,9 @@ const updateCategoryMutation = graphql`
} }
`; `;
interface CookieEntry {
name: string;
duration: string;
description: string;
}
interface Category { interface Category {
id: string; id: string;
name: string; name: string;
cookies: ReadonlyArray<CookieEntry>;
} }
interface CookieDialogProps { interface CookieDialogProps {
@@ -82,7 +71,7 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const dialogRef = useDialogRef(); const dialogRef = useDialogRef();
const [updateCategory, isUpdating] = useMutation<CookieDialogUpdateMutation>(updateCategoryMutation); const [createCookie, isCreating] = useMutation<CookieDialogCreateMutation>(createCookieMutation);
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? ""); const [categoryId, setCategoryId] = useState(categories[0]?.id ?? "");
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -92,30 +81,29 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const category = categories.find(c => c.id === categoryId); createCookie({
if (!category) return;
const existingCookies = category.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
updateCategory({
variables: { variables: {
input: { input: {
cookieCategoryId: categoryId, cookieCategoryId: categoryId,
cookies: [ name: name.trim(),
...existingCookies, duration: duration.trim(),
{ description: description.trim(),
name: name.trim(),
duration: duration.trim(),
description: description.trim(),
},
],
}, },
}, },
onCompleted() { onCompleted(_response, errors) {
if (errors?.length) {
const isConflict = errors.some(
e => (e as unknown as GraphQLError).extensions?.code === "CONFLICT",
);
toast({
title: __("Error"),
description: isConflict
? __("A cookie with this name already exists in this banner")
: errors[0].message,
variant: "error",
});
return;
}
toast({ title: __("Success"), description: __("Cookie added"), variant: "success" }); toast({ title: __("Success"), description: __("Cookie added"), variant: "success" });
dialogRef.current?.close(); dialogRef.current?.close();
}, },
@@ -163,8 +151,8 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>
<Button type="submit" disabled={isUpdating}> <Button type="submit" disabled={isCreating}>
{isUpdating ? __("Adding...") : __("Add Cookie")} {isCreating ? __("Adding...") : __("Add Cookie")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -35,8 +35,11 @@ import { useState } from "react";
import { useFragment, useMutation } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { CategorySectionCreateCookieMutation } from "#/__generated__/core/CategorySectionCreateCookieMutation.graphql";
import type { CategorySectionDeleteCookieMutation } from "#/__generated__/core/CategorySectionDeleteCookieMutation.graphql";
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql"; import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
import type { CategorySectionMoveCookieMutation } from "#/__generated__/core/CategorySectionMoveCookieMutation.graphql"; import type { CategorySectionMoveCookieMutation } from "#/__generated__/core/CategorySectionMoveCookieMutation.graphql";
import type { CategorySectionUpdateCookieMutation } from "#/__generated__/core/CategorySectionUpdateCookieMutation.graphql";
import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql"; import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql";
import { AddCookieRow } from "./AddCookieRow"; import { AddCookieRow } from "./AddCookieRow";
@@ -55,10 +58,16 @@ export const categorySectionFragment = graphql`
name name
description description
kind kind
cookies { cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) @required(action: THROW) {
name edges {
duration node {
description id
name
duration
description
...EditCookieRowFragment
}
}
} }
cookieBanner @required(action: THROW) { cookieBanner @required(action: THROW) {
categories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) { categories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) {
@@ -66,11 +75,6 @@ export const categorySectionFragment = graphql`
node { node {
id id
name name
cookies {
name
duration
description
}
} }
} }
} }
@@ -88,11 +92,55 @@ const updateCategoryMutation = graphql`
name name
description description
rank rank
cookies { updatedAt
}
cookieBanner {
id
latestVersion {
id
version
state
}
}
}
}
`;
const createCookieMutation = graphql`
mutation CategorySectionCreateCookieMutation(
$input: CreateCookieInput!
) {
createCookie(input: $input) {
cookieEdge {
node {
id
name name
duration duration
description description
} }
}
cookieBanner {
id
latestVersion {
id
version
state
}
}
}
}
`;
const updateCookieMutation = graphql`
mutation CategorySectionUpdateCookieMutation(
$input: UpdateCookieInput!
) {
updateCookie(input: $input) {
cookie {
id
name
duration
description
updatedAt updatedAt
} }
cookieBanner { cookieBanner {
@@ -107,26 +155,36 @@ const updateCategoryMutation = graphql`
} }
`; `;
const deleteCookieMutation = graphql`
mutation CategorySectionDeleteCookieMutation(
$input: DeleteCookieInput!
) {
deleteCookie(input: $input) {
deletedCookieId
cookieBanner {
id
latestVersion {
id
version
state
}
}
}
}
`;
const moveCookieMutation = graphql` const moveCookieMutation = graphql`
mutation CategorySectionMoveCookieMutation( mutation CategorySectionMoveCookieMutation(
$input: MoveCookieToCategoryInput! $input: MoveCookieToCategoryInput!
) { ) {
moveCookieToCategory(input: $input) { moveCookieToCategory(input: $input) {
sourceCookieCategory { cookie {
id id
cookies { name
name duration
duration description
description cookieCategory {
} id
updatedAt
}
targetCookieCategory {
id
cookies {
name
duration
description
} }
updatedAt updatedAt
} }
@@ -154,22 +212,29 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const [updateCategory, isUpdating] const [updateCategory, isUpdating]
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation); = useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
const [createCookie, isCreating]
= useMutation<CategorySectionCreateCookieMutation>(createCookieMutation);
const [updateCookie, isUpdatingCookie]
= useMutation<CategorySectionUpdateCookieMutation>(updateCookieMutation);
const [deleteCookie]
= useMutation<CategorySectionDeleteCookieMutation>(deleteCookieMutation);
const [moveCookie] const [moveCookie]
= useMutation<CategorySectionMoveCookieMutation>(moveCookieMutation); = useMutation<CategorySectionMoveCookieMutation>(moveCookieMutation);
const [isEditingCategory, setIsEditingCategory] = useState(false); const [isEditingCategory, setIsEditingCategory] = useState(false);
const [editingCookieIndex, setEditingCookieIndex] = useState<number | null>(null); const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
const [isAddingCookie, setIsAddingCookie] = useState(false); const [isAddingCookie, setIsAddingCookie] = useState(false);
const doUpdate = ( const cookies = category.cookies.edges.map(e => e.node);
input: Record<string, unknown>, const isMutating = isUpdating || isCreating || isUpdatingCookie;
onSuccess?: () => void,
) => { const handleSaveCategory = (name: string, description: string) => {
updateCategory({ updateCategory({
variables: { variables: {
input: { input: {
cookieCategoryId: category.id, cookieCategoryId: category.id,
...input, name,
description,
}, },
}, },
onCompleted(_response, errors) { onCompleted(_response, errors) {
@@ -186,7 +251,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
description: __("Category updated"), description: __("Category updated"),
variant: "success", variant: "success",
}); });
onSuccess?.(); setIsEditingCategory(false);
}, },
onError(error) { onError(error) {
toast({ toast({
@@ -201,62 +266,138 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
}); });
}; };
const handleSaveCategory = (name: string, description: string) => {
doUpdate({ name, description }, () => {
setIsEditingCategory(false);
});
};
const handleSaveEditCookie = (index: number, cookie: CookieEntry) => {
if (!cookie.name.trim()) return;
const newCookies = category.cookies.map((c, i) =>
i === index
? { ...cookie }
: { name: c.name, duration: c.duration, description: c.description },
);
doUpdate({ cookies: newCookies }, () => {
setEditingCookieIndex(null);
});
};
const handleDeleteCookie = (index: number) => {
const newCookies = category.cookies
.filter((_, i) => i !== index)
.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
doUpdate({ cookies: newCookies });
};
const handleSaveNewCookie = (cookie: CookieEntry) => { const handleSaveNewCookie = (cookie: CookieEntry) => {
if (!cookie.name.trim()) return; if (!cookie.name.trim()) return;
const newCookies = [ createCookie({
...category.cookies.map(c => ({ variables: {
name: c.name, input: {
duration: c.duration, cookieCategoryId: category.id,
description: c.description, name: cookie.name,
})), duration: cookie.duration,
{ ...cookie }, description: cookie.description,
]; },
doUpdate({ cookies: newCookies }, () => { },
setIsAddingCookie(false); onCompleted(_response, errors) {
if (errors?.length) {
const isConflict = errors.some(
e => (e as unknown as GraphQLError).extensions?.code === "CONFLICT",
);
toast({
title: __("Error"),
description: isConflict
? __("A cookie with this name already exists in this banner")
: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Cookie added"),
variant: "success",
});
setIsAddingCookie(false);
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to add cookie"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
const handleSaveEditCookie = (cookieId: string, cookie: CookieEntry) => {
if (!cookie.name.trim()) return;
updateCookie({
variables: {
input: {
cookieId,
name: cookie.name,
duration: cookie.duration,
description: cookie.description,
},
},
onCompleted(_response, errors) {
if (errors?.length) {
const isConflict = errors.some(
e => (e as unknown as GraphQLError).extensions?.code === "CONFLICT",
);
toast({
title: __("Error"),
description: isConflict
? __("A cookie with this name already exists in this banner")
: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Cookie updated"),
variant: "success",
});
setEditingCookieId(null);
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update cookie"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
const handleDeleteCookie = (cookieId: string) => {
deleteCookie({
variables: {
input: { cookieId },
},
onCompleted(_response, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Cookie deleted"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete cookie"),
error as GraphQLError,
),
variant: "error",
});
},
}); });
}; };
const allCategories = category.cookieBanner.categories.edges.map(e => e.node) ?? []; const allCategories = category.cookieBanner.categories.edges.map(e => e.node) ?? [];
const siblingCategories = allCategories.filter(c => c.id !== category.id); const siblingCategories = allCategories.filter(c => c.id !== category.id);
const handleMoveCookie = (cookieIndex: number, targetCategoryId: string) => { const handleMoveCookie = (cookieId: string, targetCategoryId: string) => {
const cookie = category.cookies[cookieIndex];
moveCookie({ moveCookie({
variables: { variables: {
input: { input: {
sourceCookieCategoryId: category.id, cookieId,
targetCookieCategoryId: targetCategoryId, targetCookieCategoryId: targetCategoryId,
cookieName: cookie.name,
}, },
}, },
onCompleted(_response, errors) { onCompleted(_response, errors) {
@@ -353,23 +494,19 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{category.cookies.map((cookie, index) => {cookies.map(cookie =>
editingCookieIndex === index editingCookieId === cookie.id
? ( ? (
<EditCookieRow <EditCookieRow
key={cookie.name} key={cookie.id}
cookie={{ cookieKey={cookie}
name: cookie.name, isUpdating={isMutating}
duration: cookie.duration, onSave={updated => handleSaveEditCookie(cookie.id, updated)}
description: cookie.description, onCancel={() => setEditingCookieId(null)}
}}
isUpdating={isUpdating}
onSave={updated => handleSaveEditCookie(index, updated)}
onCancel={() => setEditingCookieIndex(null)}
/> />
) )
: ( : (
<Tr key={cookie.name}> <Tr key={cookie.id}>
<Td> <Td>
<code className="text-sm font-mono">{cookie.name}</code> <code className="text-sm font-mono">{cookie.name}</code>
</Td> </Td>
@@ -384,7 +521,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setEditingCookieIndex(index); setEditingCookieId(cookie.id);
setIsAddingCookie(false); setIsAddingCookie(false);
}} }}
className="p-1 rounded cursor-pointer" className="p-1 rounded cursor-pointer"
@@ -406,7 +543,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
<DropdownItem <DropdownItem
className="text-sm" className="text-sm"
key={cat.id} key={cat.id}
onSelect={() => handleMoveCookie(index, cat.id)} onSelect={() => handleMoveCookie(cookie.id, cat.id)}
> >
{cat.name} {cat.name}
</DropdownItem> </DropdownItem>
@@ -415,7 +552,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
)} )}
<button <button
type="button" type="button"
onClick={() => handleDeleteCookie(index)} onClick={() => handleDeleteCookie(cookie.id)}
className="p-1 rounded cursor-pointer text-danger-dark" className="p-1 rounded cursor-pointer text-danger-dark"
> >
<IconTrashCan size={14} /> <IconTrashCan size={14} />
@@ -427,7 +564,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
)} )}
{isAddingCookie && ( {isAddingCookie && (
<AddCookieRow <AddCookieRow
isUpdating={isUpdating} isUpdating={isMutating}
onSave={handleSaveNewCookie} onSave={handleSaveNewCookie}
onCancel={() => setIsAddingCookie(false)} onCancel={() => setIsAddingCookie(false)}
/> />
@@ -441,7 +578,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setIsAddingCookie(true); setIsAddingCookie(true);
setEditingCookieIndex(null); setEditingCookieId(null);
}} }}
> >
<IconPlusSmall size={14} /> <IconPlusSmall size={14} />

View File

@@ -15,24 +15,41 @@
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, Input, Td, Tr } from "@probo/ui"; import { Button, Input, Td, Tr } from "@probo/ui";
import { useState } from "react"; import { useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { EditCookieRowFragment$key } from "#/__generated__/core/EditCookieRowFragment.graphql";
import type { CookieEntry } from "./CategorySection"; import type { CookieEntry } from "./CategorySection";
export const editCookieRowFragment = graphql`
fragment EditCookieRowFragment on Cookie {
name
duration
description
}
`;
interface EditCookieRowProps { interface EditCookieRowProps {
cookie: CookieEntry; cookieKey: EditCookieRowFragment$key;
isUpdating: boolean; isUpdating: boolean;
onSave: (cookie: CookieEntry) => void; onSave: (cookie: CookieEntry) => void;
onCancel: () => void; onCancel: () => void;
} }
export function EditCookieRow({ export function EditCookieRow({
cookie, cookieKey,
isUpdating, isUpdating,
onSave, onSave,
onCancel, onCancel,
}: EditCookieRowProps) { }: EditCookieRowProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const [form, setForm] = useState<CookieEntry>(cookie); const cookie = useFragment(editCookieRowFragment, cookieKey);
const [form, setForm] = useState<CookieEntry>({
name: cookie.name,
duration: cookie.duration,
description: cookie.description,
});
return ( return (
<Tr> <Tr>

View File

@@ -104,7 +104,7 @@ This ensures the compiler catches renamed or removed enum values instead of sile
| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list | | `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list |
| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows | | `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows |
| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` | | `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` |
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` | | `Update(ctx, conn, scope)` | `*Entity` | `error` | Update via `Exec` (no `RETURNING`) |
| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity | | `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity |
| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination | | `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination |
| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation | | `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation |
@@ -112,8 +112,10 @@ This ensures the compiler catches renamed or removed enum values instead of sile
## Row collection ## Row collection
Use `conn.Query` + `pgx.Collect*` only for `SELECT` and `INSERT … RETURNING` statements that return rows. For `UPDATE` and `DELETE`, use `conn.Exec` — there is no need for `RETURNING` since the caller already owns all the field values.
```go ```go
// Single row // Single row (SELECT / INSERT … RETURNING)
rows, err := conn.Query(ctx, q, args) rows, err := conn.Query(ctx, q, args)
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset]) asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -121,10 +123,13 @@ if errors.Is(err, pgx.ErrNoRows) {
} }
*a = asset *a = asset
// Multiple rows // Multiple rows (SELECT)
rows, err := conn.Query(ctx, q, args) rows, err := conn.Query(ctx, q, args)
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset]) assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
*a = assets *a = assets
// Update / Delete — no RETURNING
_, err := conn.Exec(ctx, q, args)
``` ```
## Sentinel errors ## Sentinel errors

View File

@@ -28,7 +28,8 @@ var (
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category") ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
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 in source category") ErrCookieNotFound = errors.New("cookie not found")
ErrCookieNameAlreadyExists = errors.New("a cookie with this name already exists in this banner")
ErrCategoriesBannerMismatch = errors.New("source and target categories belong to different banners") ErrCategoriesBannerMismatch = errors.New("source and target categories belong to different banners")
ErrSameCategoryMove = errors.New("source and target cookie categories must be different") ErrSameCategoryMove = errors.New("source and target cookie categories must be different")
) )

View File

@@ -66,7 +66,6 @@ type (
Name string Name string
Description string Description string
Rank int Rank int
Cookies coredata.CookieItems
} }
UpdateCookieBannerRequest struct { UpdateCookieBannerRequest struct {
@@ -82,7 +81,20 @@ type (
CookieCategoryID gid.GID CookieCategoryID gid.GID
Name *string Name *string
Description *string Description *string
Cookies *coredata.CookieItems }
CreateCookieRequest struct {
CookieCategoryID gid.GID
Name string
Duration string
Description string
}
UpdateCookieRequest struct {
CookieID gid.GID
Name *string
Duration *string
Description *string
} }
ReorderCookieCategoryRequest struct { ReorderCookieCategoryRequest struct {
@@ -91,9 +103,8 @@ type (
} }
MoveCookieToCategoryRequest struct { MoveCookieToCategoryRequest struct {
SourceCookieCategoryID gid.GID CookieID gid.GID
TargetCookieCategoryID gid.GID TargetCookieCategoryID gid.GID
CookieName string
} }
CreateCookieConsentRecordRequest struct { CreateCookieConsentRecordRequest struct {
@@ -180,6 +191,28 @@ func (r *UpdateCookieCategoryRequest) Validate() error {
return v.Error() return v.Error()
} }
func (r *CreateCookieRequest) Validate() error {
v := validator.New()
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Duration, "duration", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error()
}
func (r *UpdateCookieRequest) Validate() error {
v := validator.New()
v.Check(r.CookieID, "cookie_id", validator.Required(), validator.GID(coredata.CookieEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(255))
v.Check(r.Duration, "duration", validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error()
}
func (r *ReorderCookieCategoryRequest) Validate() error { func (r *ReorderCookieCategoryRequest) Validate() error {
v := validator.New() v := validator.New()
@@ -192,9 +225,8 @@ func (r *ReorderCookieCategoryRequest) Validate() error {
func (r *MoveCookieToCategoryRequest) Validate() error { func (r *MoveCookieToCategoryRequest) Validate() error {
v := validator.New() v := validator.New()
v.Check(r.SourceCookieCategoryID, "source_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) v.Check(r.CookieID, "cookie_id", validator.Required(), validator.GID(coredata.CookieEntityType))
v.Check(r.TargetCookieCategoryID, "target_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) v.Check(r.TargetCookieCategoryID, "target_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.CookieName, "cookie_name", validator.Required())
return v.Error() return v.Error()
} }
@@ -240,14 +272,31 @@ func CanonicalizeOrigin(raw string) string {
func buildSnapshot( func buildSnapshot(
banner *coredata.CookieBanner, banner *coredata.CookieBanner,
categories coredata.CookieCategories, categories coredata.CookieCategories,
allCookies coredata.Cookies,
) coredata.CookieBannerVersionSnapshot { ) coredata.CookieBannerVersionSnapshot {
cookiesByCategory := make(map[gid.GID]coredata.CookieItems)
for _, c := range allCookies {
cookiesByCategory[c.CookieCategoryID] = append(
cookiesByCategory[c.CookieCategoryID],
coredata.CookieItem{
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
},
)
}
snapshotCategories := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories)) snapshotCategories := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories))
for i, c := range categories { for i, c := range categories {
cookies := cookiesByCategory[c.ID]
if cookies == nil {
cookies = coredata.CookieItems{}
}
snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{ snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{
Name: c.Name, Name: c.Name,
Description: c.Description, Description: c.Description,
Kind: c.Kind, Kind: c.Kind,
Cookies: c.Cookies, Cookies: cookies,
} }
} }
@@ -265,8 +314,9 @@ func (s *Service) ensureDraftVersion(
scope coredata.Scoper, scope coredata.Scoper,
banner *coredata.CookieBanner, banner *coredata.CookieBanner,
categories coredata.CookieCategories, categories coredata.CookieCategories,
allCookies coredata.Cookies,
) (*coredata.CookieBannerVersion, error) { ) (*coredata.CookieBannerVersion, error) {
snapshot := buildSnapshot(banner, categories) snapshot := buildSnapshot(banner, categories, allCookies)
var latest coredata.CookieBannerVersion var latest coredata.CookieBannerVersion
err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID) err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID)
@@ -313,6 +363,30 @@ func (s *Service) ensureDraftVersion(
return version, nil return version, nil
} }
func (s *Service) ensureDraftVersionForBanner(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBannerVersion, error) {
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookie banner: %w", err)
}
var categories coredata.CookieCategories
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookie categories: %w", err)
}
var allCookies coredata.Cookies
if err := allCookies.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookies: %w", err)
}
return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allCookies)
}
func (s *Service) CreateCookieBanner( func (s *Service) CreateCookieBanner(
ctx context.Context, ctx context.Context,
scope coredata.Scoper, scope coredata.Scoper,
@@ -358,7 +432,6 @@ func (s *Service) CreateCookieBanner(
Description: dc.Description, Description: dc.Description,
Kind: dc.Kind, Kind: dc.Kind,
Rank: dc.Rank, Rank: dc.Rank,
Cookies: coredata.CookieItems{},
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -368,12 +441,7 @@ func (s *Service) CreateCookieBanner(
} }
} }
var categories coredata.CookieCategories if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
@@ -544,12 +612,7 @@ func (s *Service) UpdateCookieBanner(
} }
if consentChanged { if consentChanged {
var categories coredata.CookieCategories if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
} }
@@ -730,11 +793,6 @@ func (s *Service) CreateCookieCategory(
now := time.Now() now := time.Now()
cookies := req.Cookies
if cookies == nil {
cookies = coredata.CookieItems{}
}
category = &coredata.CookieCategory{ category = &coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType), ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
OrganizationID: banner.OrganizationID, OrganizationID: banner.OrganizationID,
@@ -743,7 +801,6 @@ func (s *Service) CreateCookieCategory(
Description: req.Description, Description: req.Description,
Kind: coredata.CookieCategoryKindNormal, Kind: coredata.CookieCategoryKindNormal,
Rank: req.Rank, Rank: req.Rank,
Cookies: cookies,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -752,12 +809,7 @@ func (s *Service) CreateCookieCategory(
return fmt.Errorf("cannot insert cookie category: %w", err) return fmt.Errorf("cannot insert cookie category: %w", err)
} }
var categories coredata.CookieCategories if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, req.CookieBannerID); err != nil {
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, req.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
@@ -851,6 +903,226 @@ func (s *Service) CountCookieCategoriesForBanner(
return count, nil return count, nil
} }
func (s *Service) CreateCookie(
ctx context.Context,
scope coredata.Scoper,
req CreateCookieRequest,
) (*coredata.Cookie, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var cookie *coredata.Cookie
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var category coredata.CookieCategory
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
now := time.Now()
cookie = &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: category.CookieBannerID,
CookieCategoryID: category.ID,
Name: req.Name,
Duration: req.Duration,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}
if err := cookie.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCookieNameAlreadyExists
}
return fmt.Errorf("cannot insert cookie: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cookie, nil
}
func (s *Service) GetCookie(
ctx context.Context,
scope coredata.Scoper,
cookieID gid.GID,
) (*coredata.Cookie, error) {
var cookie coredata.Cookie
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := cookie.LoadByID(ctx, conn, scope, cookieID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCookieNotFound
}
return fmt.Errorf("cannot load cookie: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &cookie, nil
}
func (s *Service) UpdateCookie(
ctx context.Context,
scope coredata.Scoper,
req UpdateCookieRequest,
) (*coredata.Cookie, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var cookie coredata.Cookie
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := cookie.LoadByID(ctx, tx, scope, req.CookieID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCookieNotFound
}
return fmt.Errorf("cannot load cookie: %w", err)
}
if req.Name != nil {
cookie.Name = *req.Name
}
if req.Duration != nil {
cookie.Duration = *req.Duration
}
if req.Description != nil {
cookie.Description = *req.Description
}
cookie.UpdatedAt = time.Now()
if err := cookie.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCookieNameAlreadyExists
}
return fmt.Errorf("cannot update cookie: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, cookie.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &cookie, nil
}
func (s *Service) DeleteCookie(
ctx context.Context,
scope coredata.Scoper,
cookieID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var cookie coredata.Cookie
if err := cookie.LoadByID(ctx, tx, scope, cookieID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCookieNotFound
}
return fmt.Errorf("cannot load cookie: %w", err)
}
if err := cookie.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete cookie: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, cookie.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
}
func (s *Service) ListCookiesForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
cursor *page.Cursor[coredata.CookieOrderField],
) (coredata.Cookies, error) {
var cookies coredata.Cookies
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := cookies.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor); err != nil {
return fmt.Errorf("cannot list cookies: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return cookies, nil
}
func (s *Service) CountCookiesForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var cookies coredata.Cookies
var err error
count, err = cookies.CountByCookieCategoryID(ctx, conn, scope, categoryID)
if err != nil {
return fmt.Errorf("cannot count cookies: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) UpdateCookieCategory( func (s *Service) UpdateCookieCategory(
ctx context.Context, ctx context.Context,
scope coredata.Scoper, scope coredata.Scoper,
@@ -878,9 +1150,6 @@ func (s *Service) UpdateCookieCategory(
if req.Description != nil { if req.Description != nil {
category.Description = *req.Description category.Description = *req.Description
} }
if req.Cookies != nil {
category.Cookies = *req.Cookies
}
category.UpdatedAt = time.Now() category.UpdatedAt = time.Now()
@@ -888,17 +1157,7 @@ func (s *Service) UpdateCookieCategory(
return fmt.Errorf("cannot update cookie category: %w", err) return fmt.Errorf("cannot update cookie category: %w", err)
} }
var banner coredata.CookieBanner if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
var categories coredata.CookieCategories
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
@@ -913,32 +1172,30 @@ func (s *Service) UpdateCookieCategory(
} }
type MoveCookieToCategoryResult struct { type MoveCookieToCategoryResult struct {
SourceCategory *coredata.CookieCategory Cookie *coredata.Cookie
TargetCategory *coredata.CookieCategory Banner *coredata.CookieBanner
Banner *coredata.CookieBanner
} }
func (s *Service) MoveCookieToCategory( func (s *Service) MoveCookieToCategory(
ctx context.Context, ctx context.Context,
scope coredata.Scoper,
req MoveCookieToCategoryRequest, req MoveCookieToCategoryRequest,
) (*MoveCookieToCategoryResult, error) { ) (*MoveCookieToCategoryResult, error) {
if err := req.Validate(); err != nil { if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err) return nil, fmt.Errorf("invalid request: %w", err)
} }
scope := coredata.NewScopeFromObjectID(req.SourceCookieCategoryID)
var result MoveCookieToCategoryResult var result MoveCookieToCategoryResult
err := s.pg.WithTx( err := s.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { func(ctx context.Context, tx pg.Tx) error {
var source coredata.CookieCategory var cookie coredata.Cookie
if err := source.LoadByID(ctx, tx, scope, req.SourceCookieCategoryID); err != nil { if err := cookie.LoadByID(ctx, tx, scope, req.CookieID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound return ErrCookieNotFound
} }
return fmt.Errorf("cannot load source cookie category: %w", err) return fmt.Errorf("cannot load cookie: %w", err)
} }
var target coredata.CookieCategory var target coredata.CookieCategory
@@ -949,57 +1206,31 @@ func (s *Service) MoveCookieToCategory(
return fmt.Errorf("cannot load target cookie category: %w", err) return fmt.Errorf("cannot load target cookie category: %w", err)
} }
if source.ID == target.ID { if cookie.CookieCategoryID == target.ID {
return ErrSameCategoryMove return ErrSameCategoryMove
} }
if source.CookieBannerID != target.CookieBannerID { if cookie.CookieBannerID != target.CookieBannerID {
return ErrCategoriesBannerMismatch return ErrCategoriesBannerMismatch
} }
cookieIdx := -1 cookie.CookieCategoryID = target.ID
for i, c := range source.Cookies { cookie.UpdatedAt = time.Now()
if c.Name == req.CookieName {
cookieIdx = i
break
}
}
if cookieIdx == -1 {
return ErrCookieNotFound
}
cookie := source.Cookies[cookieIdx] if err := cookie.Update(ctx, tx, scope); err != nil {
source.Cookies = append(source.Cookies[:cookieIdx], source.Cookies[cookieIdx+1:]...) return fmt.Errorf("cannot update cookie: %w", err)
target.Cookies = append(target.Cookies, cookie)
now := time.Now()
source.UpdatedAt = now
target.UpdatedAt = now
if err := source.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update source cookie category: %w", err)
}
if err := target.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update target cookie category: %w", err)
} }
var banner coredata.CookieBanner var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, source.CookieBannerID); err != nil { if err := banner.LoadByID(ctx, tx, scope, cookie.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err) return fmt.Errorf("cannot load cookie banner: %w", err)
} }
var categories coredata.CookieCategories if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, cookie.CookieBannerID); err != nil {
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, source.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
result.SourceCategory = &source result.Cookie = &cookie
result.TargetCategory = &target
result.Banner = &banner result.Banner = &banner
return nil return nil
@@ -1045,12 +1276,7 @@ func (s *Service) ReorderCookieCategory(
return fmt.Errorf("cannot load cookie banner: %w", err) return fmt.Errorf("cannot load cookie banner: %w", err)
} }
var categories coredata.CookieCategories if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }
@@ -1086,33 +1312,21 @@ func (s *Service) DeleteCookieCategory(
bannerID := category.CookieBannerID bannerID := category.CookieBannerID
if len(category.Cookies) > 0 { var uncategorised coredata.CookieCategory
var uncategorised coredata.CookieCategory if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil { return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
return fmt.Errorf("cannot load uncategorised cookie category: %w", err) }
}
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...) var cookies coredata.Cookies
uncategorised.UpdatedAt = time.Now() if err := cookies.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil {
if err := uncategorised.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot move cookies to uncategorised: %w", err)
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
}
} }
if err := category.Delete(ctx, tx, scope); err != nil { if err := category.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete cookie category: %w", err) return fmt.Errorf("cannot delete cookie category: %w", err)
} }
var banner coredata.CookieBanner if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, bannerID); err != nil {
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
var categories coredata.CookieCategories
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err) return fmt.Errorf("cannot ensure draft version: %w", err)
} }

400
pkg/coredata/cookie.go Normal file
View File

@@ -0,0 +1,400 @@
// 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.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Cookie struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CookieBannerID gid.GID `db:"cookie_banner_id"`
CookieCategoryID gid.GID `db:"cookie_category_id"`
Name string `db:"name"`
Duration string `db:"duration"`
Description string `db:"description"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Cookies []*Cookie
)
func (c *Cookie) CursorKey(field CookieOrderField) page.CursorKey {
switch field {
case CookieOrderFieldCreatedAt:
return page.NewCursorKey(c.ID, c.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (c *Cookie) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id FROM cookies WHERE 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 authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (c *Cookie) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
name,
duration,
description,
created_at,
updated_at
FROM
cookies
WHERE
%s
AND id = @cookie_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_id": cookieID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookies: %w", err)
}
cookie, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Cookie])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect cookie: %w", err)
}
*c = cookie
return nil
}
func (c *Cookies) LoadByCookieCategoryID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieCategoryID gid.GID,
cursor *page.Cursor[CookieOrderField],
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
name,
duration,
description,
created_at,
updated_at
FROM
cookies
WHERE
%s
AND cookie_category_id = @cookie_category_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookies: %w", err)
}
cookies, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Cookie])
if err != nil {
return fmt.Errorf("cannot collect cookies: %w", err)
}
*c = cookies
return nil
}
func (c *Cookies) CountByCookieCategoryID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieCategoryID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
cookies
WHERE
%s
AND cookie_category_id = @cookie_category_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
maps.Copy(args, scope.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 (c *Cookies) LoadAllByCookieBannerID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieBannerID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
name,
duration,
description,
created_at,
updated_at
FROM
cookies
WHERE
%s
AND cookie_banner_id = @cookie_banner_id
ORDER BY
created_at ASC, id ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookies: %w", err)
}
cookies, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Cookie])
if err != nil {
return fmt.Errorf("cannot collect cookies: %w", err)
}
*c = cookies
return nil
}
func (c *Cookie) Insert(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO cookies (
id,
tenant_id,
organization_id,
cookie_banner_id,
cookie_category_id,
name,
duration,
description,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@cookie_banner_id,
@cookie_category_id,
@name,
@duration,
@description,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID,
"cookie_category_id": c.CookieCategoryID,
"name": c.Name,
"duration": c.Duration,
"description": c.Description,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
_, err := tx.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookies_unique_name_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert cookie: %w", err)
}
return nil
}
func (c *Cookie) Update(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
UPDATE cookies
SET
cookie_category_id = @cookie_category_id,
name = @name,
duration = @duration,
description = @description,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": c.ID,
"cookie_category_id": c.CookieCategoryID,
"name": c.Name,
"duration": c.Duration,
"description": c.Description,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookies_unique_name_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update cookie: %w", err)
}
return nil
}
func (c *Cookie) Delete(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
DELETE FROM cookies
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": c.ID}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete cookie: %w", err)
}
return nil
}
func (c *Cookies) MoveToCategoryByCookieCategoryID(
ctx context.Context,
tx pg.Tx,
scope Scoper,
sourceCategoryID gid.GID,
targetCategoryID gid.GID,
) error {
q := `
UPDATE cookies
SET
cookie_category_id = @target_category_id,
updated_at = @updated_at
WHERE
%s
AND cookie_category_id = @source_category_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"source_category_id": sourceCategoryID,
"target_category_id": targetCategoryID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot move cookies to category: %w", err)
}
return nil
}

View File

@@ -45,7 +45,6 @@ type (
Description string `db:"description"` Description string `db:"description"`
Kind CookieCategoryKind `db:"kind"` Kind CookieCategoryKind `db:"kind"`
Rank int `db:"rank"` Rank int `db:"rank"`
Cookies CookieItems `db:"cookies"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -107,7 +106,6 @@ SELECT
description, description,
kind, kind,
rank, rank,
cookies,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -157,7 +155,6 @@ SELECT
description, description,
kind, kind,
rank, rank,
cookies,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -235,7 +232,6 @@ SELECT
description, description,
kind, kind,
rank, rank,
cookies,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -282,7 +278,6 @@ INSERT INTO cookie_categories (
description, description,
kind, kind,
rank, rank,
cookies,
created_at, created_at,
updated_at updated_at
) VALUES ( ) VALUES (
@@ -294,7 +289,6 @@ INSERT INTO cookie_categories (
@description, @description,
@kind, @kind,
@rank, @rank,
@cookies,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -309,7 +303,6 @@ INSERT INTO cookie_categories (
"description": c.Description, "description": c.Description,
"kind": c.Kind, "kind": c.Kind,
"rank": c.Rank, "rank": c.Rank,
"cookies": c.Cookies,
"created_at": c.CreatedAt, "created_at": c.CreatedAt,
"updated_at": c.UpdatedAt, "updated_at": c.UpdatedAt,
} }
@@ -332,22 +325,10 @@ UPDATE cookie_categories
SET SET
name = @name, name = @name,
description = @description, description = @description,
cookies = @cookies,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
%s %s
AND id = @id AND id = @id
RETURNING
id,
organization_id,
cookie_banner_id,
name,
description,
kind,
rank,
cookies,
created_at,
updated_at
` `
q = fmt.Sprintf(q, scope.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment())
@@ -356,23 +337,15 @@ RETURNING
"id": c.ID, "id": c.ID,
"name": c.Name, "name": c.Name,
"description": c.Description, "description": c.Description,
"cookies": c.Cookies,
"updated_at": c.UpdatedAt, "updated_at": c.UpdatedAt,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
rows, err := tx.Query(ctx, q, args) _, err := tx.Exec(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot update cookie category: %w", err) return fmt.Errorf("cannot update cookie category: %w", err)
} }
category, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieCategory])
if err != nil {
return fmt.Errorf("cannot collect updated cookie category: %w", err)
}
*c = category
return nil return nil
} }
@@ -465,7 +438,6 @@ SELECT
description, description,
kind, kind,
rank, rank,
cookies,
created_at, created_at,
updated_at updated_at
FROM FROM

View File

@@ -0,0 +1,55 @@
// 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.
package coredata
import "fmt"
type CookieOrderField string
const (
CookieOrderFieldCreatedAt CookieOrderField = "CREATED_AT"
)
func (p CookieOrderField) Column() string {
switch p {
case CookieOrderFieldCreatedAt:
return "created_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
}
func (p CookieOrderField) IsValid() bool {
switch p {
case CookieOrderFieldCreatedAt:
return true
}
return false
}
func (p CookieOrderField) String() string {
return string(p)
}
func (p *CookieOrderField) UnmarshalText(text []byte) error {
*p = CookieOrderField(text)
if !p.IsValid() {
return fmt.Errorf("%s is not a valid CookieOrderField", string(text))
}
return nil
}
func (p CookieOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}

View File

@@ -108,6 +108,7 @@ const (
OAuth2RefreshTokenEntityType uint16 = 82 OAuth2RefreshTokenEntityType uint16 = 82
OAuth2AuthorizationCodeEntityType uint16 = 83 OAuth2AuthorizationCodeEntityType uint16 = 83
OAuth2DeviceCodeEntityType uint16 = 84 OAuth2DeviceCodeEntityType uint16 = 84
CookieEntityType uint16 = 85
) )
func NewEntityFromID(id gid.GID) (any, bool) { func NewEntityFromID(id gid.GID) (any, bool) {
@@ -272,6 +273,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &OAuth2AuthorizationCode{ID: id}, true return &OAuth2AuthorizationCode{ID: id}, true
case OAuth2DeviceCodeEntityType: case OAuth2DeviceCodeEntityType:
return &OAuth2DeviceCode{ID: id}, true return &OAuth2DeviceCode{ID: id}, true
case CookieEntityType:
return &Cookie{ID: id}, true
default: default:
return nil, false return nil, false
} }

View File

@@ -0,0 +1,31 @@
-- 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.
CREATE TABLE cookies (
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,
cookie_category_id TEXT NOT NULL REFERENCES cookie_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
duration TEXT NOT NULL,
description TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE UNIQUE INDEX idx_cookies_unique_name_per_banner
ON cookies (cookie_banner_id, name);
ALTER TABLE cookie_categories DROP COLUMN cookies;

View File

@@ -398,4 +398,11 @@ const (
ActionCookieCategoryCreate = "core:cookie-category:create" ActionCookieCategoryCreate = "core:cookie-category:create"
ActionCookieCategoryUpdate = "core:cookie-category:update" ActionCookieCategoryUpdate = "core:cookie-category:update"
ActionCookieCategoryDelete = "core:cookie-category:delete" ActionCookieCategoryDelete = "core:cookie-category:delete"
// Cookie actions
ActionCookieGet = "core:cookie:get"
ActionCookieList = "core:cookie:list"
ActionCookieCreate = "core:cookie:create"
ActionCookieUpdate = "core:cookie:update"
ActionCookieDelete = "core:cookie:delete"
) )

View File

@@ -87,6 +87,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionCookieBannerGet, ActionCookieBannerList, ActionCookieBannerGet, ActionCookieBannerList,
ActionCookieBannerVersionGet, ActionCookieBannerVersionList, ActionCookieBannerVersionGet, ActionCookieBannerVersionList,
ActionCookieCategoryGet, ActionCookieCategoryList, ActionCookieCategoryGet, ActionCookieCategoryList,
ActionCookieGet, ActionCookieList,
).WithSID("entity-read-access").When(organizationCondition), ).WithSID("entity-read-access").When(organizationCondition),
policy.Allow( policy.Allow(

View File

@@ -20,6 +20,16 @@ import (
"go.probo.inc/probo/pkg/validator" "go.probo.inc/probo/pkg/validator"
) )
// CookieCategory is the resolver for the cookieCategory field.
func (r *cookieResolver) CookieCategory(ctx context.Context, obj *types.Cookie) (*types.CookieCategory, error) {
return obj.CookieCategory, nil
}
// Permission is the resolver for the permission field.
func (r *cookieResolver) Permission(ctx context.Context, obj *types.Cookie, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// Organization is the resolver for the organization field. // Organization is the resolver for the organization field.
func (r *cookieBannerResolver) Organization(ctx context.Context, obj *types.CookieBanner) (*types.Organization, error) { func (r *cookieBannerResolver) Organization(ctx context.Context, obj *types.CookieBanner) (*types.Organization, error) {
return obj.Organization, nil return obj.Organization, nil
@@ -120,6 +130,37 @@ func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.Co
return obj.CookieBanner, nil return obj.CookieBanner, nil
} }
// Cookies is the resolver for the cookies field.
func (r *cookieCategoryResolver) Cookies(ctx context.Context, obj *types.CookieCategory, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieOrderBy) (*types.CookieConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionCookieList); err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.CookieOrderField]{
Field: coredata.CookieOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.CookieOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
scope := coredata.NewScopeFromObjectID(obj.ID)
cookies, err := r.cookieBanner.ListCookiesForCategory(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list cookies", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
p := page.NewPage(cookies, cursor)
return types.NewCookieConnection(p, r, obj.ID), nil
}
// Permission is the resolver for the permission field. // Permission is the resolver for the permission field.
func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) { func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action) return r.Resolver.Permission(ctx, obj, action)
@@ -142,6 +183,23 @@ func (r *cookieCategoryConnectionResolver) TotalCount(ctx context.Context, obj *
return count, nil return count, nil
} }
// TotalCount is the resolver for the totalCount field.
func (r *cookieConnectionResolver) TotalCount(ctx context.Context, obj *types.CookieConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionCookieList); err != nil {
return 0, err
}
scope := coredata.NewScopeFromObjectID(obj.ParentID)
count, err := r.cookieBanner.CountCookiesForCategory(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count cookies", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// CreateCookieBanner is the resolver for the createCookieBanner field. // CreateCookieBanner is the resolver for the createCookieBanner field.
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) { func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil { if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil {
@@ -335,18 +393,6 @@ func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types
scope := coredata.NewScopeFromObjectID(input.CookieBannerID) scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
var cookies coredata.CookieItems
if input.Cookies != nil {
cookies = make(coredata.CookieItems, len(input.Cookies))
for i, c := range input.Cookies {
cookies[i] = coredata.CookieItem{
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
}
}
}
category, err := r.cookieBanner.CreateCookieCategory( category, err := r.cookieBanner.CreateCookieCategory(
ctx, ctx,
scope, scope,
@@ -355,7 +401,6 @@ func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types
Name: input.Name, Name: input.Name,
Description: input.Description, Description: input.Description,
Rank: input.Rank, Rank: input.Rank,
Cookies: cookies,
}, },
) )
if err != nil { if err != nil {
@@ -389,19 +434,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
scope := coredata.NewScopeFromObjectID(input.CookieCategoryID) scope := coredata.NewScopeFromObjectID(input.CookieCategoryID)
var cookies *coredata.CookieItems
if input.Cookies != nil {
items := make(coredata.CookieItems, len(input.Cookies))
for i, c := range input.Cookies {
items[i] = coredata.CookieItem{
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
}
}
cookies = &items
}
category, err := r.cookieBanner.UpdateCookieCategory( category, err := r.cookieBanner.UpdateCookieCategory(
ctx, ctx,
scope, scope,
@@ -409,7 +441,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
CookieCategoryID: input.CookieCategoryID, CookieCategoryID: input.CookieCategoryID,
Name: input.Name, Name: input.Name,
Description: input.Description, Description: input.Description,
Cookies: cookies,
}, },
) )
if err != nil { if err != nil {
@@ -514,7 +545,7 @@ func (r *mutationResolver) ReorderCookieCategory(ctx context.Context, input type
// MoveCookieToCategory is the resolver for the moveCookieToCategory field. // MoveCookieToCategory is the resolver for the moveCookieToCategory field.
func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types.MoveCookieToCategoryInput) (*types.MoveCookieToCategoryPayload, error) { func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types.MoveCookieToCategoryInput) (*types.MoveCookieToCategoryPayload, error) {
if err := r.authorize(ctx, input.SourceCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil { if err := r.authorize(ctx, input.CookieID, probo.ActionCookieUpdate); err != nil {
return nil, err return nil, err
} }
@@ -522,12 +553,14 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
return nil, err return nil, err
} }
scope := coredata.NewScopeFromObjectID(input.CookieID)
result, err := r.cookieBanner.MoveCookieToCategory( result, err := r.cookieBanner.MoveCookieToCategory(
ctx, ctx,
scope,
cookiebanner.MoveCookieToCategoryRequest{ cookiebanner.MoveCookieToCategoryRequest{
SourceCookieCategoryID: input.SourceCookieCategoryID, CookieID: input.CookieID,
TargetCookieCategoryID: input.TargetCookieCategoryID, TargetCookieCategoryID: input.TargetCookieCategoryID,
CookieName: input.CookieName,
}, },
) )
if err != nil { if err != nil {
@@ -537,7 +570,7 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
case errors.Is(err, cookiebanner.ErrCookieNotFound): case errors.Is(err, cookiebanner.ErrCookieNotFound):
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch): case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch):
return nil, gqlutils.NotFoundf(ctx, "source or target category not found") return nil, gqlutils.NotFoundf(ctx, "cookie or target category not found")
default: default:
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)
@@ -548,12 +581,145 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
} }
return &types.MoveCookieToCategoryPayload{ return &types.MoveCookieToCategoryPayload{
SourceCookieCategory: types.NewCookieCategory(result.SourceCategory), Cookie: types.NewCookie(result.Cookie),
TargetCookieCategory: types.NewCookieCategory(result.TargetCategory), CookieBanner: types.NewCookieBanner(result.Banner),
CookieBanner: types.NewCookieBanner(result.Banner),
}, nil }, nil
} }
// CreateCookie is the resolver for the createCookie field.
func (r *mutationResolver) CreateCookie(ctx context.Context, input types.CreateCookieInput) (*types.CreateCookiePayload, error) {
if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionCookieCreate); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(input.CookieCategoryID)
cookie, err := r.cookieBanner.CreateCookie(
ctx,
scope,
cookiebanner.CreateCookieRequest{
CookieCategoryID: input.CookieCategoryID,
Name: input.Name,
Duration: input.Duration,
Description: input.Description,
},
)
if err != nil {
if errors.Is(err, cookiebanner.ErrCookieNameAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
if errors.Is(err, cookiebanner.ErrCategoryNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot create cookie", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bannerScope := coredata.NewScopeFromObjectID(cookie.CookieBannerID)
banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, cookie.CookieBannerID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateCookiePayload{
CookieEdge: types.NewCookieEdge(cookie, coredata.CookieOrderFieldCreatedAt),
CookieBanner: types.NewCookieBanner(banner),
}, nil
}
// UpdateCookie is the resolver for the updateCookie field.
func (r *mutationResolver) UpdateCookie(ctx context.Context, input types.UpdateCookieInput) (*types.UpdateCookiePayload, error) {
if err := r.authorize(ctx, input.CookieID, probo.ActionCookieUpdate); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(input.CookieID)
cookie, err := r.cookieBanner.UpdateCookie(
ctx,
scope,
cookiebanner.UpdateCookieRequest{
CookieID: input.CookieID,
Name: input.Name,
Duration: input.Duration,
Description: input.Description,
},
)
if err != nil {
if errors.Is(err, cookiebanner.ErrCookieNameAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
if errors.Is(err, cookiebanner.ErrCookieNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot update cookie", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bannerScope := coredata.NewScopeFromObjectID(cookie.CookieBannerID)
banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, cookie.CookieBannerID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateCookiePayload{
Cookie: types.NewCookie(cookie),
CookieBanner: types.NewCookieBanner(banner),
}, nil
}
// DeleteCookie is the resolver for the deleteCookie field.
func (r *mutationResolver) DeleteCookie(ctx context.Context, input types.DeleteCookieInput) (*types.DeleteCookiePayload, error) {
if err := r.authorize(ctx, input.CookieID, probo.ActionCookieDelete); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(input.CookieID)
cookie, err := r.cookieBanner.GetCookie(ctx, scope, input.CookieID)
if err != nil {
if errors.Is(err, cookiebanner.ErrCookieNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get cookie", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bannerID := cookie.CookieBannerID
err = r.cookieBanner.DeleteCookie(ctx, scope, input.CookieID)
if err != nil {
if errors.Is(err, cookiebanner.ErrCookieNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot delete cookie", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bannerScope := coredata.NewScopeFromObjectID(bannerID)
banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, bannerID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteCookiePayload{
DeletedCookieID: input.CookieID,
CookieBanner: types.NewCookieBanner(banner),
}, nil
}
// Cookie returns schema.CookieResolver implementation.
func (r *Resolver) Cookie() schema.CookieResolver { return &cookieResolver{r} }
// CookieBanner returns schema.CookieBannerResolver implementation. // CookieBanner returns schema.CookieBannerResolver implementation.
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} } func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }
@@ -570,7 +736,14 @@ func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionRes
return &cookieCategoryConnectionResolver{r} return &cookieCategoryConnectionResolver{r}
} }
// CookieConnection returns schema.CookieConnectionResolver implementation.
func (r *Resolver) CookieConnection() schema.CookieConnectionResolver {
return &cookieConnectionResolver{r}
}
type cookieResolver struct{ *Resolver }
type cookieBannerResolver struct{ *Resolver } type cookieBannerResolver struct{ *Resolver }
type cookieBannerConnectionResolver struct{ *Resolver } type cookieBannerConnectionResolver struct{ *Resolver }
type cookieCategoryResolver struct{ *Resolver } type cookieCategoryResolver struct{ *Resolver }
type cookieCategoryConnectionResolver struct{ *Resolver } type cookieCategoryConnectionResolver struct{ *Resolver }
type cookieConnectionResolver struct{ *Resolver }

View File

@@ -110,17 +110,63 @@ type CookieCategory implements Node {
description: String! description: String!
kind: CookieCategoryKind! kind: CookieCategoryKind!
rank: Int! rank: Int!
cookies: [CookieItem!]!
cookies(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: CookieOrder
): CookieConnection @goField(forceResolver: true)
createdAt: Datetime! createdAt: Datetime!
updatedAt: Datetime! updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true) permission(action: String!): Boolean! @goField(forceResolver: true)
} }
type CookieItem { enum CookieOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.CookieOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.CookieOrderFieldCreatedAt"
)
}
input CookieOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieOrderBy"
) {
direction: OrderDirection!
field: CookieOrderField!
}
type Cookie implements Node {
id: ID!
cookieCategory: CookieCategory @goField(forceResolver: true)
name: String! name: String!
duration: String! duration: String!
description: String! description: String!
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type CookieConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [CookieEdge!]!
pageInfo: PageInfo!
}
type CookieEdge {
cursor: CursorKey!
node: Cookie!
} }
type CookieBannerVersion implements Node { type CookieBannerVersion implements Node {
@@ -193,6 +239,9 @@ extend type Mutation {
moveCookieToCategory( moveCookieToCategory(
input: MoveCookieToCategoryInput! input: MoveCookieToCategoryInput!
): MoveCookieToCategoryPayload! ): MoveCookieToCategoryPayload!
createCookie(input: CreateCookieInput!): CreateCookiePayload!
updateCookie(input: UpdateCookieInput!): UpdateCookiePayload!
deleteCookie(input: DeleteCookieInput!): DeleteCookiePayload!
} }
input CreateCookieBannerInput { input CreateCookieBannerInput {
@@ -234,14 +283,12 @@ input CreateCookieCategoryInput {
name: String! name: String!
description: String! description: String!
rank: Int! rank: Int!
cookies: [CookieItemInput!]
} }
input UpdateCookieCategoryInput { input UpdateCookieCategoryInput {
cookieCategoryId: ID! cookieCategoryId: ID!
name: String name: String
description: String description: String
cookies: [CookieItemInput!]
} }
input DeleteCookieCategoryInput { input DeleteCookieCategoryInput {
@@ -253,16 +300,27 @@ input ReorderCookieCategoryInput {
rank: Int! rank: Int!
} }
input CookieItemInput { input MoveCookieToCategoryInput {
cookieId: ID!
targetCookieCategoryId: ID!
}
input CreateCookieInput {
cookieCategoryId: ID!
name: String! name: String!
duration: String! duration: String!
description: String! description: String!
} }
input MoveCookieToCategoryInput { input UpdateCookieInput {
sourceCookieCategoryId: ID! cookieId: ID!
targetCookieCategoryId: ID! name: String
cookieName: String! duration: String
description: String
}
input DeleteCookieInput {
cookieId: ID!
} }
type CreateCookieBannerPayload { type CreateCookieBannerPayload {
@@ -310,7 +368,21 @@ type ReorderCookieCategoryPayload {
} }
type MoveCookieToCategoryPayload { type MoveCookieToCategoryPayload {
sourceCookieCategory: CookieCategory! cookie: Cookie!
targetCookieCategory: CookieCategory! cookieBanner: CookieBanner!
}
type CreateCookiePayload {
cookieEdge: CookieEdge!
cookieBanner: CookieBanner!
}
type UpdateCookiePayload {
cookie: Cookie!
cookieBanner: CookieBanner!
}
type DeleteCookiePayload {
deletedCookieId: ID!
cookieBanner: CookieBanner! cookieBanner: CookieBanner!
} }

View File

@@ -0,0 +1,78 @@
// 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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
CookieOrderBy OrderBy[coredata.CookieOrderField]
CookieConnection struct {
TotalCount int
Edges []*CookieEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewCookieConnection(
p *page.Page[*coredata.Cookie, coredata.CookieOrderField],
parentType any,
parentID gid.GID,
) *CookieConnection {
edges := make([]*CookieEdge, len(p.Data))
for i := range edges {
edges[i] = NewCookieEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &CookieConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewCookieEdge(c *coredata.Cookie, orderBy coredata.CookieOrderField) *CookieEdge {
return &CookieEdge{
Cursor: c.CursorKey(orderBy),
Node: NewCookie(c),
}
}
func NewCookie(c *coredata.Cookie) *Cookie {
return &Cookie{
ID: c.ID,
CookieCategory: &CookieCategory{
ID: c.CookieCategoryID,
CookieBanner: &CookieBanner{
ID: c.CookieBannerID,
},
},
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}

View File

@@ -61,15 +61,6 @@ func NewCookieCategoryEdge(c *coredata.CookieCategory, orderBy coredata.CookieCa
} }
func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory { func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
cookies := make([]*CookieItem, len(c.Cookies))
for i, cookie := range c.Cookies {
cookies[i] = &CookieItem{
Name: cookie.Name,
Duration: cookie.Duration,
Description: cookie.Description,
}
}
return &CookieCategory{ return &CookieCategory{
ID: c.ID, ID: c.ID,
CookieBanner: &CookieBanner{ CookieBanner: &CookieBanner{
@@ -79,7 +70,6 @@ func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
Description: c.Description, Description: c.Description,
Kind: c.Kind, Kind: c.Kind,
Rank: c.Rank, Rank: c.Rank,
Cookies: cookies,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt, UpdatedAt: c.UpdatedAt,
} }