From 6ed28512efec44a440d53075c393a093b39e25be Mon Sep 17 00:00:00 2001 From: yuriassuncx Date: Thu, 29 Oct 2026 00:14:45 -0300 Subject: [PATCH] fix(google-tag-manager): keep untouched fields on update, add create_version GTM's PUT replaces the whole entity, so update_tag/update_trigger/update_variable silently deleted every field the tool did not forward: consentSettings (the Consent Mode/LGPD check), parentFolderId, setupTag/teardownTag, priority and schedule. The client now reads the current entity and overlays only the fields the caller set. Also: - get/list/update tag expose consentSettings; update_tag accepts it and parentFolderId - variable formatValue is an options object, not a Parameter; the old schema made list_variables/get_variable fail output validation - create_version tool (creates a version from a workspace, never publishes) Co-Authored-By: Claude Opus 5.5 --- google-tag-manager/package.json | 3 +- .../server/lib/gtm-client.test.ts | 50 +++++++++++++++++ google-tag-manager/server/lib/gtm-client.ts | 53 +++++++++++++------ google-tag-manager/server/lib/types.ts | 17 +++++- google-tag-manager/server/tools/index.ts | 2 +- google-tag-manager/server/tools/tags.ts | 24 ++++++++- google-tag-manager/server/tools/triggers.ts | 2 +- google-tag-manager/server/tools/variables.ts | 9 +++- google-tag-manager/server/tools/workspaces.ts | 46 ++++++++++++++++ 9 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 google-tag-manager/server/lib/gtm-client.test.ts diff --git a/google-tag-manager/package.json b/google-tag-manager/package.json index 5896495e..5f0ee9ed 100644 --- a/google-tag-manager/package.json +++ b/google-tag-manager/package.json @@ -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", diff --git a/google-tag-manager/server/lib/gtm-client.test.ts b/google-tag-manager/server/lib/gtm-client.test.ts new file mode 100644 index 00000000..9b7bab71 --- /dev/null +++ b/google-tag-manager/server/lib/gtm-client.test.ts @@ -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 | 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); + }); +}); diff --git a/google-tag-manager/server/lib/gtm-client.ts b/google-tag-manager/server/lib/gtm-client.ts index 0e6a3931..11473a5c 100644 --- a/google-tag-manager/server/lib/gtm-client.ts +++ b/google-tag-manager/server/lib/gtm-client.ts @@ -25,6 +25,7 @@ import type { UpdateTriggerInput, CreateVariableInput, UpdateVariableInput, + CreateVersionResponse, } from "./types.ts"; export interface GTMClientConfig { @@ -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(url: string, input: object): Promise { + const current = await this.request>(url); + const changes = Object.fromEntries( + Object.entries(input).filter(([, v]) => v !== undefined), + ); + return this.request(url, { + method: "PUT", + body: JSON.stringify({ ...current, ...changes }), + }); + } + // ==================== Account Methods ==================== /** @@ -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 { + return this.request( + `${ENDPOINTS.WORKSPACE(accountId, containerId, workspaceId)}:create_version`, + { method: "POST", body: JSON.stringify(input) }, + ); + } + // ==================== Tag Methods ==================== /** @@ -252,12 +284,9 @@ export class GTMClient { tagId: string, input: UpdateTagInput, ): Promise { - return this.request( + return this.mergedPut( ENDPOINTS.TAG(accountId, containerId, workspaceId, tagId), - { - method: "PUT", - body: JSON.stringify(input), - }, + input, ); } @@ -341,12 +370,9 @@ export class GTMClient { triggerId: string, input: UpdateTriggerInput, ): Promise { - return this.request( + return this.mergedPut( ENDPOINTS.TRIGGER(accountId, containerId, workspaceId, triggerId), - { - method: "PUT", - body: JSON.stringify(input), - }, + input, ); } @@ -430,12 +456,9 @@ export class GTMClient { variableId: string, input: UpdateVariableInput, ): Promise { - return this.request( + return this.mergedPut( ENDPOINTS.VARIABLE(accountId, containerId, workspaceId, variableId), - { - method: "PUT", - body: JSON.stringify(input), - }, + input, ); } diff --git a/google-tag-manager/server/lib/types.ts b/google-tag-manager/server/lib/types.ts index de8f07c0..eab6621c 100644 --- a/google-tag-manager/server/lib/types.ts +++ b/google-tag-manager/server/lib/types.ts @@ -173,7 +173,7 @@ export interface Variable { disablingTriggerId?: string[]; enablingTriggerId?: string[]; tagManagerUrl: string; - formatValue?: Parameter; + formatValue?: Record; } /** @@ -294,6 +294,7 @@ export interface UpdateTagInput { liveOnly?: boolean; parentFolderId?: string; paused?: boolean; + consentSettings?: TagConsentSetting; } /** @@ -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; +} diff --git a/google-tag-manager/server/tools/index.ts b/google-tag-manager/server/tools/index.ts index ef7af658..3fb53836 100644 --- a/google-tag-manager/server/tools/index.ts +++ b/google-tag-manager/server/tools/index.ts @@ -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) diff --git a/google-tag-manager/server/tools/tags.ts b/google-tag-manager/server/tools/tags.ts index f8228435..be9099ac 100644 --- a/google-tag-manager/server/tools/tags.ts +++ b/google-tag-manager/server/tools/tags.ts @@ -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 @@ -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"), @@ -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)", + ), }); // ============================================================================ @@ -131,6 +139,7 @@ export const createListTagsTool = (env: Env) => tagManagerUrl: tag.tagManagerUrl, parentFolderId: tag.parentFolderId, paused: tag.paused, + consentSettings: tag.consentSettings, })), nextPageToken: response.nextPageToken, }; @@ -187,6 +196,7 @@ export const createGetTagTool = (env: Env) => tagManagerUrl: tag.tagManagerUrl, parentFolderId: tag.parentFolderId, paused: tag.paused, + consentSettings: tag.consentSettings, }, }; }, @@ -276,6 +286,7 @@ export const createCreateTagTool = (env: Env) => tagManagerUrl: tag.tagManagerUrl, parentFolderId: tag.parentFolderId, paused: tag.paused, + consentSettings: tag.consentSettings, }, }; }, @@ -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')"), @@ -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"), @@ -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, }, ); @@ -367,6 +386,7 @@ export const createUpdateTagTool = (env: Env) => tagManagerUrl: tag.tagManagerUrl, parentFolderId: tag.parentFolderId, paused: tag.paused, + consentSettings: tag.consentSettings, }, }; }, diff --git a/google-tag-manager/server/tools/triggers.ts b/google-tag-manager/server/tools/triggers.ts index 80afc634..052f74ba 100644 --- a/google-tag-manager/server/tools/triggers.ts +++ b/google-tag-manager/server/tools/triggers.ts @@ -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')"), diff --git a/google-tag-manager/server/tools/variables.ts b/google-tag-manager/server/tools/variables.ts index b5fc73bb..1e301fbd 100644 --- a/google-tag-manager/server/tools/variables.ts +++ b/google-tag-manager/server/tools/variables.ts @@ -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, ...)", + ), }); // ============================================================================ @@ -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')"), diff --git a/google-tag-manager/server/tools/workspaces.ts b/google-tag-manager/server/tools/workspaces.ts index a6b59515..dcc32e58 100644 --- a/google-tag-manager/server/tools/workspaces.ts +++ b/google-tag-manager/server/tools/workspaces.ts @@ -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 // ============================================================================ @@ -172,4 +217,5 @@ export const workspaceTools = [ createListWorkspacesTool, createGetWorkspaceTool, createCreateWorkspaceTool, + createCreateVersionTool, ];