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
kind
rank
cookies {
name
duration
description
}
createdAt
updatedAt
}

View File

@@ -31,22 +31,18 @@ import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieDialogUpdateMutation } from "#/__generated__/core/CookieDialogUpdateMutation.graphql";
import type { CookieDialogCreateMutation } from "#/__generated__/core/CookieDialogCreateMutation.graphql";
const updateCategoryMutation = graphql`
mutation CookieDialogUpdateMutation($input: UpdateCookieCategoryInput!) {
updateCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
const createCookieMutation = graphql`
mutation CookieDialogCreateMutation($input: CreateCookieInput!) {
createCookie(input: $input) {
cookieEdge {
node {
id
name
duration
description
}
updatedAt
}
cookieBanner {
id
@@ -60,16 +56,9 @@ const updateCategoryMutation = graphql`
}
`;
interface CookieEntry {
name: string;
duration: string;
description: string;
}
interface Category {
id: string;
name: string;
cookies: ReadonlyArray<CookieEntry>;
}
interface CookieDialogProps {
@@ -82,7 +71,7 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
const { toast } = useToast();
const dialogRef = useDialogRef();
const [updateCategory, isUpdating] = useMutation<CookieDialogUpdateMutation>(updateCategoryMutation);
const [createCookie, isCreating] = useMutation<CookieDialogCreateMutation>(createCookieMutation);
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? "");
const [name, setName] = useState("");
@@ -92,30 +81,29 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const category = categories.find(c => c.id === categoryId);
if (!category) return;
const existingCookies = category.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
updateCategory({
createCookie({
variables: {
input: {
cookieCategoryId: categoryId,
cookies: [
...existingCookies,
{
name: name.trim(),
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" });
dialogRef.current?.close();
},
@@ -163,8 +151,8 @@ export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? __("Adding...") : __("Add Cookie")}
<Button type="submit" disabled={isCreating}>
{isCreating ? __("Adding...") : __("Add Cookie")}
</Button>
</DialogFooter>
</form>

View File

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

View File

@@ -15,24 +15,41 @@
import { useTranslate } from "@probo/i18n";
import { Button, Input, Td, Tr } from "@probo/ui";
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";
export const editCookieRowFragment = graphql`
fragment EditCookieRowFragment on Cookie {
name
duration
description
}
`;
interface EditCookieRowProps {
cookie: CookieEntry;
cookieKey: EditCookieRowFragment$key;
isUpdating: boolean;
onSave: (cookie: CookieEntry) => void;
onCancel: () => void;
}
export function EditCookieRow({
cookie,
cookieKey,
isUpdating,
onSave,
onCancel,
}: EditCookieRowProps) {
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 (
<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 |
| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows |
| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` |
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` |
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update via `Exec` (no `RETURNING`) |
| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity |
| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination |
| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation |
@@ -112,8 +112,10 @@ This ensures the compiler catches renamed or removed enum values instead of sile
## 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
// Single row
// Single row (SELECT / INSERT … RETURNING)
rows, err := conn.Query(ctx, q, args)
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if errors.Is(err, pgx.ErrNoRows) {
@@ -121,10 +123,13 @@ if errors.Is(err, pgx.ErrNoRows) {
}
*a = asset
// Multiple rows
// Multiple rows (SELECT)
rows, err := conn.Query(ctx, q, args)
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
*a = assets
// Update / Delete — no RETURNING
_, err := conn.Exec(ctx, q, args)
```
## Sentinel errors

View File

@@ -28,7 +28,8 @@ var (
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner")
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")
ErrSameCategoryMove = errors.New("source and target cookie categories must be different")
)

View File

@@ -66,7 +66,6 @@ type (
Name string
Description string
Rank int
Cookies coredata.CookieItems
}
UpdateCookieBannerRequest struct {
@@ -82,7 +81,20 @@ type (
CookieCategoryID gid.GID
Name *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 {
@@ -91,9 +103,8 @@ type (
}
MoveCookieToCategoryRequest struct {
SourceCookieCategoryID gid.GID
CookieID gid.GID
TargetCookieCategoryID gid.GID
CookieName string
}
CreateCookieConsentRecordRequest struct {
@@ -180,6 +191,28 @@ func (r *UpdateCookieCategoryRequest) Validate() 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 {
v := validator.New()
@@ -192,9 +225,8 @@ func (r *ReorderCookieCategoryRequest) Validate() error {
func (r *MoveCookieToCategoryRequest) Validate() error {
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.CookieName, "cookie_name", validator.Required())
return v.Error()
}
@@ -240,14 +272,31 @@ func CanonicalizeOrigin(raw string) string {
func buildSnapshot(
banner *coredata.CookieBanner,
categories coredata.CookieCategories,
allCookies coredata.Cookies,
) 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))
for i, c := range categories {
cookies := cookiesByCategory[c.ID]
if cookies == nil {
cookies = coredata.CookieItems{}
}
snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{
Name: c.Name,
Description: c.Description,
Kind: c.Kind,
Cookies: c.Cookies,
Cookies: cookies,
}
}
@@ -265,8 +314,9 @@ func (s *Service) ensureDraftVersion(
scope coredata.Scoper,
banner *coredata.CookieBanner,
categories coredata.CookieCategories,
allCookies coredata.Cookies,
) (*coredata.CookieBannerVersion, error) {
snapshot := buildSnapshot(banner, categories)
snapshot := buildSnapshot(banner, categories, allCookies)
var latest coredata.CookieBannerVersion
err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID)
@@ -313,6 +363,30 @@ func (s *Service) ensureDraftVersion(
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(
ctx context.Context,
scope coredata.Scoper,
@@ -358,7 +432,6 @@ func (s *Service) CreateCookieBanner(
Description: dc.Description,
Kind: dc.Kind,
Rank: dc.Rank,
Cookies: coredata.CookieItems{},
CreatedAt: now,
UpdatedAt: now,
}
@@ -368,12 +441,7 @@ func (s *Service) CreateCookieBanner(
}
}
var categories coredata.CookieCategories
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
@@ -544,12 +612,7 @@ func (s *Service) UpdateCookieBanner(
}
if consentChanged {
var categories coredata.CookieCategories
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
@@ -730,11 +793,6 @@ func (s *Service) CreateCookieCategory(
now := time.Now()
cookies := req.Cookies
if cookies == nil {
cookies = coredata.CookieItems{}
}
category = &coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
OrganizationID: banner.OrganizationID,
@@ -743,7 +801,6 @@ func (s *Service) CreateCookieCategory(
Description: req.Description,
Kind: coredata.CookieCategoryKindNormal,
Rank: req.Rank,
Cookies: cookies,
CreatedAt: now,
UpdatedAt: now,
}
@@ -752,12 +809,7 @@ func (s *Service) CreateCookieCategory(
return fmt.Errorf("cannot insert cookie category: %w", err)
}
var categories coredata.CookieCategories
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, req.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
@@ -851,6 +903,226 @@ func (s *Service) CountCookieCategoriesForBanner(
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(
ctx context.Context,
scope coredata.Scoper,
@@ -878,9 +1150,6 @@ func (s *Service) UpdateCookieCategory(
if req.Description != nil {
category.Description = *req.Description
}
if req.Cookies != nil {
category.Cookies = *req.Cookies
}
category.UpdatedAt = time.Now()
@@ -888,17 +1157,7 @@ func (s *Service) UpdateCookieCategory(
return fmt.Errorf("cannot update cookie category: %w", err)
}
var banner coredata.CookieBanner
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
@@ -913,32 +1172,30 @@ func (s *Service) UpdateCookieCategory(
}
type MoveCookieToCategoryResult struct {
SourceCategory *coredata.CookieCategory
TargetCategory *coredata.CookieCategory
Banner *coredata.CookieBanner
Cookie *coredata.Cookie
Banner *coredata.CookieBanner
}
func (s *Service) MoveCookieToCategory(
ctx context.Context,
scope coredata.Scoper,
req MoveCookieToCategoryRequest,
) (*MoveCookieToCategoryResult, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
scope := coredata.NewScopeFromObjectID(req.SourceCookieCategoryID)
var result MoveCookieToCategoryResult
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var source coredata.CookieCategory
if err := source.LoadByID(ctx, tx, scope, req.SourceCookieCategoryID); err != nil {
var cookie coredata.Cookie
if err := cookie.LoadByID(ctx, tx, scope, req.CookieID); err != nil {
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
@@ -949,57 +1206,31 @@ func (s *Service) MoveCookieToCategory(
return fmt.Errorf("cannot load target cookie category: %w", err)
}
if source.ID == target.ID {
if cookie.CookieCategoryID == target.ID {
return ErrSameCategoryMove
}
if source.CookieBannerID != target.CookieBannerID {
if cookie.CookieBannerID != target.CookieBannerID {
return ErrCategoriesBannerMismatch
}
cookieIdx := -1
for i, c := range source.Cookies {
if c.Name == req.CookieName {
cookieIdx = i
break
}
}
if cookieIdx == -1 {
return ErrCookieNotFound
}
cookie.CookieCategoryID = target.ID
cookie.UpdatedAt = time.Now()
cookie := source.Cookies[cookieIdx]
source.Cookies = append(source.Cookies[:cookieIdx], source.Cookies[cookieIdx+1:]...)
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)
if err := cookie.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update cookie: %w", err)
}
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)
}
var categories coredata.CookieCategories
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, cookie.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
result.SourceCategory = &source
result.TargetCategory = &target
result.Cookie = &cookie
result.Banner = &banner
return nil
@@ -1045,12 +1276,7 @@ func (s *Service) ReorderCookieCategory(
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
@@ -1086,33 +1312,21 @@ func (s *Service) DeleteCookieCategory(
bannerID := category.CookieBannerID
if len(category.Cookies) > 0 {
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
}
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...)
uncategorised.UpdatedAt = time.Now()
if err := uncategorised.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
}
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
}
var cookies coredata.Cookies
if err := cookies.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil {
return fmt.Errorf("cannot move cookies to uncategorised: %w", err)
}
if err := category.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete cookie category: %w", err)
}
var banner coredata.CookieBanner
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 {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, bannerID); err != nil {
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"`
Kind CookieCategoryKind `db:"kind"`
Rank int `db:"rank"`
Cookies CookieItems `db:"cookies"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -107,7 +106,6 @@ SELECT
description,
kind,
rank,
cookies,
created_at,
updated_at
FROM
@@ -157,7 +155,6 @@ SELECT
description,
kind,
rank,
cookies,
created_at,
updated_at
FROM
@@ -235,7 +232,6 @@ SELECT
description,
kind,
rank,
cookies,
created_at,
updated_at
FROM
@@ -282,7 +278,6 @@ INSERT INTO cookie_categories (
description,
kind,
rank,
cookies,
created_at,
updated_at
) VALUES (
@@ -294,7 +289,6 @@ INSERT INTO cookie_categories (
@description,
@kind,
@rank,
@cookies,
@created_at,
@updated_at
)
@@ -309,7 +303,6 @@ INSERT INTO cookie_categories (
"description": c.Description,
"kind": c.Kind,
"rank": c.Rank,
"cookies": c.Cookies,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
@@ -332,22 +325,10 @@ UPDATE cookie_categories
SET
name = @name,
description = @description,
cookies = @cookies,
updated_at = @updated_at
WHERE
%s
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())
@@ -356,23 +337,15 @@ RETURNING
"id": c.ID,
"name": c.Name,
"description": c.Description,
"cookies": c.Cookies,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
rows, err := tx.Query(ctx, q, args)
_, err := tx.Exec(ctx, q, args)
if err != nil {
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
}
@@ -465,7 +438,6 @@ SELECT
description,
kind,
rank,
cookies,
created_at,
updated_at
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
OAuth2AuthorizationCodeEntityType uint16 = 83
OAuth2DeviceCodeEntityType uint16 = 84
CookieEntityType uint16 = 85
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -272,6 +273,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &OAuth2AuthorizationCode{ID: id}, true
case OAuth2DeviceCodeEntityType:
return &OAuth2DeviceCode{ID: id}, true
case CookieEntityType:
return &Cookie{ID: id}, true
default:
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"
ActionCookieCategoryUpdate = "core:cookie-category:update"
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,
ActionCookieBannerVersionGet, ActionCookieBannerVersionList,
ActionCookieCategoryGet, ActionCookieCategoryList,
ActionCookieGet, ActionCookieList,
).WithSID("entity-read-access").When(organizationCondition),
policy.Allow(

View File

@@ -20,6 +20,16 @@ import (
"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.
func (r *cookieBannerResolver) Organization(ctx context.Context, obj *types.CookieBanner) (*types.Organization, error) {
return obj.Organization, nil
@@ -120,6 +130,37 @@ func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.Co
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.
func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -142,6 +183,23 @@ func (r *cookieCategoryConnectionResolver) TotalCount(ctx context.Context, obj *
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.
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
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)
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(
ctx,
scope,
@@ -355,7 +401,6 @@ func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types
Name: input.Name,
Description: input.Description,
Rank: input.Rank,
Cookies: cookies,
},
)
if err != nil {
@@ -389,19 +434,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
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(
ctx,
scope,
@@ -409,7 +441,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
CookieCategoryID: input.CookieCategoryID,
Name: input.Name,
Description: input.Description,
Cookies: cookies,
},
)
if err != nil {
@@ -514,7 +545,7 @@ func (r *mutationResolver) ReorderCookieCategory(ctx context.Context, input type
// MoveCookieToCategory is the resolver for the moveCookieToCategory field.
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
}
@@ -522,12 +553,14 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
return nil, err
}
scope := coredata.NewScopeFromObjectID(input.CookieID)
result, err := r.cookieBanner.MoveCookieToCategory(
ctx,
scope,
cookiebanner.MoveCookieToCategoryRequest{
SourceCookieCategoryID: input.SourceCookieCategoryID,
CookieID: input.CookieID,
TargetCookieCategoryID: input.TargetCookieCategoryID,
CookieName: input.CookieName,
},
)
if err != nil {
@@ -537,7 +570,7 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
case errors.Is(err, cookiebanner.ErrCookieNotFound):
return nil, gqlutils.NotFound(ctx, err)
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:
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
@@ -548,12 +581,145 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
}
return &types.MoveCookieToCategoryPayload{
SourceCookieCategory: types.NewCookieCategory(result.SourceCategory),
TargetCookieCategory: types.NewCookieCategory(result.TargetCategory),
CookieBanner: types.NewCookieBanner(result.Banner),
Cookie: types.NewCookie(result.Cookie),
CookieBanner: types.NewCookieBanner(result.Banner),
}, 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.
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }
@@ -570,7 +736,14 @@ func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionRes
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 cookieBannerConnectionResolver struct{ *Resolver }
type cookieCategoryResolver struct{ *Resolver }
type cookieCategoryConnectionResolver struct{ *Resolver }
type cookieConnectionResolver struct{ *Resolver }

View File

@@ -110,17 +110,63 @@ type CookieCategory implements Node {
description: String!
kind: CookieCategoryKind!
rank: Int!
cookies: [CookieItem!]!
cookies(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: CookieOrder
): CookieConnection @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
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!
duration: 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 {
@@ -193,6 +239,9 @@ extend type Mutation {
moveCookieToCategory(
input: MoveCookieToCategoryInput!
): MoveCookieToCategoryPayload!
createCookie(input: CreateCookieInput!): CreateCookiePayload!
updateCookie(input: UpdateCookieInput!): UpdateCookiePayload!
deleteCookie(input: DeleteCookieInput!): DeleteCookiePayload!
}
input CreateCookieBannerInput {
@@ -234,14 +283,12 @@ input CreateCookieCategoryInput {
name: String!
description: String!
rank: Int!
cookies: [CookieItemInput!]
}
input UpdateCookieCategoryInput {
cookieCategoryId: ID!
name: String
description: String
cookies: [CookieItemInput!]
}
input DeleteCookieCategoryInput {
@@ -253,16 +300,27 @@ input ReorderCookieCategoryInput {
rank: Int!
}
input CookieItemInput {
input MoveCookieToCategoryInput {
cookieId: ID!
targetCookieCategoryId: ID!
}
input CreateCookieInput {
cookieCategoryId: ID!
name: String!
duration: String!
description: String!
}
input MoveCookieToCategoryInput {
sourceCookieCategoryId: ID!
targetCookieCategoryId: ID!
cookieName: String!
input UpdateCookieInput {
cookieId: ID!
name: String
duration: String
description: String
}
input DeleteCookieInput {
cookieId: ID!
}
type CreateCookieBannerPayload {
@@ -310,7 +368,21 @@ type ReorderCookieCategoryPayload {
}
type MoveCookieToCategoryPayload {
sourceCookieCategory: CookieCategory!
targetCookieCategory: CookieCategory!
cookie: Cookie!
cookieBanner: CookieBanner!
}
type CreateCookiePayload {
cookieEdge: CookieEdge!
cookieBanner: CookieBanner!
}
type UpdateCookiePayload {
cookie: Cookie!
cookieBanner: CookieBanner!
}
type DeleteCookiePayload {
deletedCookieId: ID!
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 {
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{
ID: c.ID,
CookieBanner: &CookieBanner{
@@ -79,7 +70,6 @@ func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
Description: c.Description,
Kind: c.Kind,
Rank: c.Rank,
Cookies: cookies,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}