Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion google-tag-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js",
"build": "bun run build:server",
"publish": "cat app.json | deco registry publish -w /shared/deco -y",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@decocms/runtime": "1.2.5",
Expand Down
50 changes: 50 additions & 0 deletions google-tag-manager/server/lib/gtm-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from "bun:test";
import { GTMClient } from "./gtm-client.ts";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

describe("updateTag", () => {
it("keeps the fields the caller did not send (GTM PUT replaces the entity)", async () => {
const current = {
tagId: "7",
name: "Clarity",
type: "html",
fingerprint: "1",
parentFolderId: "644",
consentSettings: {
consentStatus: "needed",
consentType: {
type: "list",
list: [{ type: "template", value: "analytics_storage" }],
},
},
setupTag: [{ tagName: "loader" }],
firingTriggerId: ["2147479553"],
};
let sent: Record<string, unknown> | undefined;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
if (init?.method === "PUT") {
sent = JSON.parse(init.body as string);
return new Response(JSON.stringify(sent));
}
return new Response(JSON.stringify(current));
}) as typeof fetch;

await new GTMClient({ accessToken: "t" }).updateTag("1", "2", "3", "7", {
fingerprint: "1",
name: "Clarity",
type: "html",
firingTriggerId: ["2147479553", "703"],
notes: undefined,
});

expect(sent?.firingTriggerId).toEqual(["2147479553", "703"]);
expect(sent?.consentSettings).toEqual(current.consentSettings);
expect(sent?.parentFolderId).toBe("644");
expect(sent?.setupTag).toEqual(current.setupTag);
expect("notes" in (sent ?? {})).toBe(false);
});
});
53 changes: 38 additions & 15 deletions google-tag-manager/server/lib/gtm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
UpdateTriggerInput,
CreateVariableInput,
UpdateVariableInput,
CreateVersionResponse,
} from "./types.ts";

export interface GTMClientConfig {
Expand Down Expand Up @@ -66,6 +67,22 @@ export class GTMClient {
return (await response.json()) as T;
}

/**
* GTM's PUT replaces the whole entity: any field left out (consentSettings,
* parentFolderId, setupTag, priority, schedule...) is deleted. Read the current
* entity and overlay only the fields the caller actually set.
*/
private async mergedPut<T>(url: string, input: object): Promise<T> {
const current = await this.request<Record<string, unknown>>(url);
const changes = Object.fromEntries(
Object.entries(input).filter(([, v]) => v !== undefined),
);
return this.request<T>(url, {
method: "PUT",
body: JSON.stringify({ ...current, ...changes }),
});
}

// ==================== Account Methods ====================

/**
Expand Down Expand Up @@ -191,6 +208,21 @@ export class GTMClient {
);
}

/**
* Create a container version from a workspace (does not publish it)
*/
async createVersion(
accountId: string,
containerId: string,
workspaceId: string,
input: { name: string; notes?: string },
): Promise<CreateVersionResponse> {
return this.request<CreateVersionResponse>(
`${ENDPOINTS.WORKSPACE(accountId, containerId, workspaceId)}:create_version`,
{ method: "POST", body: JSON.stringify(input) },
);
}

// ==================== Tag Methods ====================

/**
Expand Down Expand Up @@ -252,12 +284,9 @@ export class GTMClient {
tagId: string,
input: UpdateTagInput,
): Promise<Tag> {
return this.request<Tag>(
return this.mergedPut<Tag>(
ENDPOINTS.TAG(accountId, containerId, workspaceId, tagId),
{
method: "PUT",
body: JSON.stringify(input),
},
input,
);
}

Expand Down Expand Up @@ -341,12 +370,9 @@ export class GTMClient {
triggerId: string,
input: UpdateTriggerInput,
): Promise<Trigger> {
return this.request<Trigger>(
return this.mergedPut<Trigger>(
ENDPOINTS.TRIGGER(accountId, containerId, workspaceId, triggerId),
{
method: "PUT",
body: JSON.stringify(input),
},
input,
);
}

Expand Down Expand Up @@ -430,12 +456,9 @@ export class GTMClient {
variableId: string,
input: UpdateVariableInput,
): Promise<Variable> {
return this.request<Variable>(
return this.mergedPut<Variable>(
ENDPOINTS.VARIABLE(accountId, containerId, workspaceId, variableId),
{
method: "PUT",
body: JSON.stringify(input),
},
input,
);
}

Expand Down
17 changes: 16 additions & 1 deletion google-tag-manager/server/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export interface Variable {
disablingTriggerId?: string[];
enablingTriggerId?: string[];
tagManagerUrl: string;
formatValue?: Parameter;
formatValue?: Record<string, unknown>;
}

/**
Expand Down Expand Up @@ -294,6 +294,7 @@ export interface UpdateTagInput {
liveOnly?: boolean;
parentFolderId?: string;
paused?: boolean;
consentSettings?: TagConsentSetting;
}

/**
Expand Down Expand Up @@ -353,3 +354,17 @@ export interface UpdateVariableInput {
disablingTriggerId?: string[];
enablingTriggerId?: string[];
}

/**
* Response of workspaces.create_version
*/
export interface CreateVersionResponse {
containerVersion?: {
containerVersionId: string;
name?: string;
tagManagerUrl?: string;
};
compilerError?: boolean;
syncStatus?: { mergeConflict?: boolean; syncError?: boolean };
newWorkspacePath?: string;
}
2 changes: 1 addition & 1 deletion google-tag-manager/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Tools:
* - accountTools: Account management (list, get)
* - containerTools: Container management (list, get, create, delete)
* - workspaceTools: Workspace management (list, get, create)
* - workspaceTools: Workspace management (list, get, create, create_version)
* - tagTools: Tag management (list, get, create, update, delete)
* - triggerTools: Trigger management (list, get, create, update, delete)
* - variableTools: Variable management (list, get, create, update, delete)
Expand Down
24 changes: 22 additions & 2 deletions google-tag-manager/server/tools/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createPrivateTool } from "@decocms/runtime/tools";
import { z } from "zod";
import type { Env } from "../main.ts";
import { GTMClient, getAccessToken } from "../lib/gtm-client.ts";
import type { Parameter } from "../lib/types.ts";
import type { Parameter, TagConsentSetting } from "../lib/types.ts";

// ============================================================================
// Schema Definitions
Expand All @@ -32,6 +32,11 @@ const ParameterSchema: z.ZodTypeAny = z.lazy(() =>
}),
);

const ConsentSettingsSchema = z.object({
consentStatus: z.enum(["notSet", "notNeeded", "needed"]).optional(),
consentType: ParameterSchema.optional(),
});

const TagSchema = z.object({
path: z.string().describe("Tag path"),
accountId: z.string().describe("Account ID"),
Expand Down Expand Up @@ -71,6 +76,9 @@ const TagSchema = z.object({
tagManagerUrl: z.string().describe("Tag Manager URL for this tag"),
parentFolderId: z.string().optional().describe("Parent folder ID"),
paused: z.boolean().optional().describe("Whether tag is paused"),
consentSettings: ConsentSettingsSchema.optional().describe(
"Additional consent checks (consentStatus needed + consentType list)",
),
});

// ============================================================================
Expand Down Expand Up @@ -131,6 +139,7 @@ export const createListTagsTool = (env: Env) =>
tagManagerUrl: tag.tagManagerUrl,
parentFolderId: tag.parentFolderId,
paused: tag.paused,
consentSettings: tag.consentSettings,
})),
nextPageToken: response.nextPageToken,
};
Expand Down Expand Up @@ -187,6 +196,7 @@ export const createGetTagTool = (env: Env) =>
tagManagerUrl: tag.tagManagerUrl,
parentFolderId: tag.parentFolderId,
paused: tag.paused,
consentSettings: tag.consentSettings,
},
};
},
Expand Down Expand Up @@ -276,6 +286,7 @@ export const createCreateTagTool = (env: Env) =>
tagManagerUrl: tag.tagManagerUrl,
parentFolderId: tag.parentFolderId,
paused: tag.paused,
consentSettings: tag.consentSettings,
},
};
},
Expand All @@ -289,7 +300,7 @@ export const createUpdateTagTool = (env: Env) =>
createPrivateTool({
id: "update_tag",
description:
"Update an existing GTM tag. All fields from the original tag must be provided.",
"Update an existing GTM tag. Only the fields you pass change; everything else (consent settings, folder, setup/teardown tags, schedule) is kept.",
inputSchema: z.object({
accountId: z.string().describe("Account ID (e.g., '12345')"),
containerId: z.string().describe("Container ID (e.g., '67890')"),
Expand Down Expand Up @@ -318,6 +329,10 @@ export const createUpdateTagTool = (env: Env) =>
.optional()
.describe("Whether tag only fires in live mode"),
paused: z.boolean().optional().describe("Whether tag is paused"),
parentFolderId: z.string().optional().describe("Parent folder ID"),
consentSettings: ConsentSettingsSchema.optional().describe(
"Additional consent checks; omit to keep the current ones",
),
}),
outputSchema: z.object({
tag: TagSchema.describe("Updated tag"),
Expand All @@ -343,6 +358,10 @@ export const createUpdateTagTool = (env: Env) =>
notes: context.notes,
liveOnly: context.liveOnly,
paused: context.paused,
parentFolderId: context.parentFolderId,
consentSettings: context.consentSettings as
| TagConsentSetting
| undefined,
},
);

Expand All @@ -367,6 +386,7 @@ export const createUpdateTagTool = (env: Env) =>
tagManagerUrl: tag.tagManagerUrl,
parentFolderId: tag.parentFolderId,
paused: tag.paused,
consentSettings: tag.consentSettings,
},
};
},
Expand Down
2 changes: 1 addition & 1 deletion google-tag-manager/server/tools/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export const createUpdateTriggerTool = (env: Env) =>
createPrivateTool({
id: "update_trigger",
description:
"Update an existing GTM trigger. All fields from the original trigger must be provided.",
"Update an existing GTM trigger. Only the fields you pass change; everything else (consent settings, folder, setup/teardown tags, schedule) is kept.",
inputSchema: z.object({
accountId: z.string().describe("Account ID (e.g., '12345')"),
containerId: z.string().describe("Container ID (e.g., '67890')"),
Expand Down
9 changes: 7 additions & 2 deletions google-tag-manager/server/tools/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ const VariableSchema = z.object({
.describe("Triggers that enable this variable"),
tagManagerUrl: z.string().describe("Tag Manager URL for this variable"),
parentFolderId: z.string().optional().describe("Parent folder ID"),
formatValue: ParameterSchema.optional().describe("Format value parameter"),
formatValue: z
.record(z.string(), z.unknown())
.optional()
.describe(
"Format value options (caseConversionType, convertNullToValue, ...)",
),
});

// ============================================================================
Expand Down Expand Up @@ -273,7 +278,7 @@ export const createUpdateVariableTool = (env: Env) =>
createPrivateTool({
id: "update_variable",
description:
"Update an existing GTM variable. All fields from the original variable must be provided.",
"Update an existing GTM variable. Only the fields you pass change; everything else (consent settings, folder, setup/teardown tags, schedule) is kept.",
inputSchema: z.object({
accountId: z.string().describe("Account ID (e.g., '12345')"),
containerId: z.string().describe("Container ID (e.g., '67890')"),
Expand Down
46 changes: 46 additions & 0 deletions google-tag-manager/server/tools/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,51 @@ export const createCreateWorkspaceTool = (env: Env) =>
},
});

// ============================================================================
// Create Version Tool
// ============================================================================

export const createCreateVersionTool = (env: Env) =>
createPrivateTool({
id: "create_version",
description:
"Create a container version from a workspace. It does NOT publish: the version must be published in the GTM UI. GTM deletes the workspace after the version is created.",
inputSchema: z.object({
accountId: z.string().describe("Account ID (e.g., '12345')"),
containerId: z.string().describe("Container ID (e.g., '67890')"),
workspaceId: z.string().describe("Workspace ID (e.g., '5')"),
name: z.string().describe("Version name"),
notes: z.string().optional().describe("Version notes"),
}),
outputSchema: z.object({
containerVersionId: z.string().optional(),
tagManagerUrl: z.string().optional(),
compilerError: z.boolean().optional(),
mergeConflict: z.boolean().optional(),
syncError: z.boolean().optional(),
}),
execute: async ({ context }) => {
const client = new GTMClient({
accessToken: getAccessToken(env),
});

const res = await client.createVersion(
context.accountId,
context.containerId,
context.workspaceId,
{ name: context.name, notes: context.notes },
);

return {
containerVersionId: res.containerVersion?.containerVersionId,
tagManagerUrl: res.containerVersion?.tagManagerUrl,
compilerError: res.compilerError,
mergeConflict: res.syncStatus?.mergeConflict,
syncError: res.syncStatus?.syncError,
};
},
});

// ============================================================================
// Export all workspace tools
// ============================================================================
Expand All @@ -172,4 +217,5 @@ export const workspaceTools = [
createListWorkspacesTool,
createGetWorkspaceTool,
createCreateWorkspaceTool,
createCreateVersionTool,
];
Loading