@getvitops/utils 8.3.0-alpha.4 → 8.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/ads/index.d.mts +10 -91
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +1 -314
- package/dist/ads/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +1 -1
- package/dist/notify/index.d.mts +1 -1
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +1 -0
- package/dist/notify/index.mjs.map +1 -1
- package/dist/providers-CEHw-j4v.mjs +351 -0
- package/dist/providers-CEHw-j4v.mjs.map +1 -0
- package/dist/tracking/index.d.mts +53 -2
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +111 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-oac2P5or.d.mts → types-ByK6OuXQ.d.mts} +6 -1
- package/dist/{types-oac2P5or.d.mts.map → types-ByK6OuXQ.d.mts.map} +1 -1
- package/dist/types-C0hQKdsM.d.mts +92 -0
- package/dist/types-C0hQKdsM.d.mts.map +1 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n\n/**\n * Whether a form post has anywhere to go: a `notifications.email` channel, a location e-mail,\n * or a CRM. With none of the three the emdash template's `/api/track` refuses the post (it\n * would otherwise accept it and lose the message), so `vitops emdash check-prerender` asks the\n * same question of the config before the first visitor finds out. One function so the build\n * check and the route cannot disagree.\n */\nexport function hasFormDelivery(\n ctx: Pick<NotifyContext, 'notifications' | 'locationEmails'>,\n crm?: unknown,\n): boolean {\n return Boolean(ctx.notifications?.email || ctx.locationEmails?.length || crm);\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * One line, already rendered both ways — a plain fact (`html` is just the\n * escaped `text`) or a link (`html` an anchor, `text` the bare href). Built at\n * the point each line is known rather than carried as a union and branched on\n * later: `describeEvent`'s lines are always plain `[label, value]` pairs (see\n * `Section` above, which `render.test.ts` destructures as such), and the one\n * line shape `renderEmail` adds itself — the \"Actions\" link — is turned into\n * this same rendered shape immediately, so the two renderers below share one\n * line format instead of branching on what kind of line each one is.\n */\ninterface RenderedLine {\n label: string;\n text: string;\n html: string;\n}\n\n/** A section as `renderEmail` assembles it — `describeEvent`'s output plus its own additions. */\ninterface RenderSection {\n heading: string;\n lines: RenderedLine[];\n}\n\nfunction factLine([label, value]: readonly [string, string]): RenderedLine {\n return { label, text: value, html: escapeHtml(value) };\n}\n\nfunction linkLine(label: string, href: string): RenderedLine {\n return { label, text: href, html: `<a href=\"${escapeHtml(href)}\">${escapeHtml(href)}</a>` };\n}\n\nfunction toRenderSection(section: Section): RenderSection {\n return { heading: section.heading, lines: section.lines.map(factLine) };\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n return identifySubmitter(formData).fullName;\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else if (event.type === 'email') {\n const email = event.email ?? 'unknown';\n subject = `[Email Click] Visitor emailing ${email}`;\n contact.push(['Email', email]);\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(\n event: ConversionEvent,\n extra: { notes?: string[] | undefined; actionUrl?: string | undefined } = {},\n): RenderedMessage {\n const { subject, sections: eventSections } = describeEvent(event);\n\n // `{clickId}` is the only placeholder — an event has no durable id nothing\n // in this toolchain could look back up (there's no store), so interpolating\n // one would be decorative. Omitted entirely when there's no click ID to\n // interpolate, rather than emailing a link with a literal `{clickId}` in it.\n const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;\n const actionSection: RenderSection | null =\n extra.actionUrl && clickId\n ? {\n heading: 'Actions',\n lines: [\n linkLine(\n 'View in ad platform',\n extra.actionUrl.replace(/\\{clickId\\}/g, encodeURIComponent(clickId.value)),\n ),\n ],\n }\n : null;\n\n // Deliberately a channel-neutral `string[]` rather than a typed CRM result —\n // this module gains no dependency on `crm`, and the seam serves any future\n // sink the same way.\n const sections: RenderSection[] = [\n ...eventSections.map(toRenderSection),\n ...(actionSection ? [actionSection] : []),\n ...(extra.notes && extra.notes.length\n ? [{ heading: 'Delivery', lines: extra.notes.map((n) => factLine(['CRM', n])) }]\n : []),\n ];\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n (l) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(l.label)}</td>` +\n `<td style=\"padding:2px 0\">${l.html}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Google Ads offline conversion upload — the server-side counterpart to the\n * client-side `gtag('event', 'conversion', …)` call `gtagBootstrap`\n * (`@getvitops/utils/ads`) wires up. Reads `site.ads.google.reporting: 'server'`\n * and a matching `conversionActionId` (see that field's own docblock for why\n * it is a different identifier from `conversionLabel`, not an alternate\n * spelling of it) and calls `ConversionUploadService.UploadClickConversions`\n * instead of relying on a visitor's browser to fire the tag.\n *\n * Same reasoning as `@getvitops/utils/crm`'s executors: credentials are a\n * structurally-typed shape the consumer's route supplies from env vars, never\n * config, and a failed upload is a `GoogleAdsUploadResult` to log or fold into\n * the notification email — never an exception, and never something that fails\n * the conversion request it's attached to. A lost ad report is not a lost\n * lead; the visitor has already submitted.\n *\n * **Structurally typed against Google's documented REST shape, not verified\n * against a live Ads account** — no developer-token/MCC access exists\n * anywhere in this toolchain's own setup to test against (the same blocker\n * `vitops ads provision` is waiting on). Same caveat, and the same reason, as\n * `@getvitops/utils/crm`'s Jobber executor: wrapped so a wrong guess about the\n * exact request/response shape degrades to a stated failure reason rather\n * than breaking. Re-verify against `googleads.googleapis.com`'s current API\n * version and `ClickConversion` message shape before depending on this in\n * production, and update this docblock with what was found.\n */\nimport { errorDetail, googleAccessToken, googleHeaders } from '../google/token.ts';\nimport type { GoogleOAuth } from '../google/token.ts';\n\n/** Bumped independently of everything else here — the Ads API deprecates\n * versions on its own rolling schedule. Last checked against Google's public\n * docs at the time this file was written; not re-verified since. */\nconst API_VERSION = 'v17';\n\nexport interface GoogleAdsCredentials {\n /** Issued against a Google Ads manager (MCC) account — a Google review, not\n * a self-service toggle. Never config. */\n developerToken: string;\n oauth: GoogleOAuth;\n /** The MCC's own (dashes-or-not) customer id — required when `customerId`\n * in the upload input is a client account managed under it, which is the\n * common case for an agency running this on a client's behalf. */\n loginCustomerId?: string | undefined;\n}\n\nexport interface GoogleAdsUploadInput {\n /** Google Ads customer id, as shown in the UI (`\"123-456-7890\"`) or bare\n * digits — either works, dashes are stripped before the call. */\n customerId: string;\n /** A numeric conversion action id, or a full\n * `customers/…/conversionActions/…` resource name. */\n conversionActionId: string;\n /** The click id captured on `_ac` — `getPrimaryClickId()`'s result. */\n clickId: { param: string; value: string };\n /** Epoch ms — converted to the Ads API's own\n * `\"yyyy-MM-dd HH:mm:ss+00:00\"` format. */\n at: number;\n conversionValue?: number | undefined;\n currencyCode?: string | undefined;\n orderId?: string | undefined;\n}\n\n/** Never thrown — an Ads API outage or a malformed request both come back as\n * `ok: false`, the same \"structured result, not an exception\" rule every\n * other executor in this toolchain follows. */\nexport interface GoogleAdsUploadResult {\n ok: boolean;\n reason?: string | undefined;\n code?: string | undefined;\n}\n\nfunction digitsOnly(id: string): string {\n return id.replace(/\\D/g, '');\n}\n\n/** A bare numeric id becomes a full resource name under `customerId`; a value\n * already shaped like one (the escape hatch, matching `txtRecord`'s `=`\n * check in `@getvitops/utils/ads`) passes through unchanged. */\nexport function conversionActionResourceName(\n customerId: string,\n conversionActionId: string,\n): string {\n if (conversionActionId.includes('customers/')) return conversionActionId;\n return `customers/${digitsOnly(customerId)}/conversionActions/${conversionActionId}`;\n}\n\n/** The Ads API's documented click-conversion datetime format, always\n * rendered in UTC — there is no per-account timezone lookup here to do\n * better, and UTC is unambiguous. */\nexport function toGoogleConversionDateTime(atMs: number): string {\n const d = new Date(atMs);\n const pad = (n: number): string => String(n).padStart(2, '0');\n return (\n `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +\n `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+00:00`\n );\n}\n\n/** The request field a click id param maps to — the API takes `gclid`/\n * `gbraid`/`wbraid` as distinct fields, not one generic \"click id\" field.\n * Anything else this toolchain's `PLATFORM_PARAMS` maps to Google (today,\n * only these three) falls back to `gclid`. */\nfunction clickIdField(param: string): 'gclid' | 'gbraid' | 'wbraid' {\n if (param === 'gbraid' || param === 'wbraid') return param;\n return 'gclid';\n}\n\n/**\n * Upload one click conversion via `ConversionUploadService.UploadClickConversions`.\n *\n * `partialFailure: true` is always sent, and a 200 response carrying\n * `partialFailureError` is treated as a failure, not a success — a single-\n * conversion batch makes \"partial\" and \"total\" failure the same case, and\n * Google Ads API responses are 200 for a partial failure by design (silently\n * treating that as success is exactly the trap `@getvitops/utils/crm`'s\n * Jobber executor documents for its own GraphQL transport).\n */\nexport async function uploadClickConversion(\n input: GoogleAdsUploadInput,\n credentials: GoogleAdsCredentials,\n fetchImpl: typeof fetch = fetch,\n): Promise<GoogleAdsUploadResult> {\n let token: string;\n try {\n token = await googleAccessToken({ kind: 'oauth', oauth: credentials.oauth }, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const customerId = digitsOnly(input.customerId);\n const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadClickConversions`;\n\n const body = {\n conversions: [\n {\n conversionAction: conversionActionResourceName(customerId, input.conversionActionId),\n [clickIdField(input.clickId.param)]: input.clickId.value,\n conversionDateTime: toGoogleConversionDateTime(input.at),\n ...(input.conversionValue != null ? { conversionValue: input.conversionValue } : {}),\n ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),\n ...(input.orderId ? { orderId: input.orderId } : {}),\n },\n ],\n partialFailure: true,\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: googleHeaders(token, {\n 'developer-token': credentials.developerToken,\n 'content-type': 'application/json',\n ...(credentials.loginCustomerId\n ? { 'login-customer-id': digitsOnly(credentials.loginCustomerId) }\n : {}),\n }),\n body: JSON.stringify(body),\n });\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (!res.ok) {\n return {\n ok: false,\n code: String(res.status),\n reason: await errorDetail(res, (status) =>\n status === 401\n ? 'Google Ads rejected the access token.'\n : status === 403\n ? 'Google Ads refused the request — check the developer token is approved and `login-customer-id` is set for an MCC-managed account.'\n : undefined,\n ),\n };\n }\n\n const json = (await res.json().catch(() => ({}))) as {\n partialFailureError?: { message?: string };\n };\n if (json.partialFailureError) {\n return {\n ok: false,\n reason: `Google Ads rejected the conversion: ${json.partialFailureError.message ?? 'no detail given'}`,\n };\n }\n\n return { ok: true };\n}\n\n/** A one-line delivery note, matching `describeCrmResult`'s shape — what\n * `conversion.ts` folds into the notification email's \"Delivery\" section. */\nexport function describeGoogleAdsOfflineResult(\n result: GoogleAdsUploadResult | null,\n): string | null {\n if (!result) return null;\n if (result.ok) return 'Reported to Google Ads (server-side)';\n return `⚠ Not reported to Google Ads: ${result.reason ?? 'unknown reason'}`;\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * `google-ads.ts` is a fourth file, added later, that does **not** follow the\n * three-file shape above: it isn't a `NotificationsConfig` channel `notify()`\n * plans and dispatches, it's a separate delivery keyed to `site.ads.google`,\n * wired in by `createConversionRoute`'s own `googleAdsOffline` option and\n * folded into the email's \"Delivery\" section exactly like `@getvitops/utils/\n * crm`'s `CrmResult` — see that module's `describeCrmResult` for the\n * precedent this follows (`describeGoogleAdsOfflineResult` here).\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n extra: { notes?: string[] | undefined } = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event, { ...extra, actionUrl: ctx.notifications?.actionUrl });\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n hasFormDelivery,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport {\n conversionActionResourceName,\n describeGoogleAdsOfflineResult,\n toGoogleConversionDateTime,\n uploadClickConversion,\n} from './google-ads.ts';\nexport type {\n GoogleAdsCredentials,\n GoogleAdsUploadInput,\n GoogleAdsUploadResult,\n} from './google-ads.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n\n/**\n * Whether a form post has anywhere to go: a `notifications.email` channel, a location e-mail,\n * or a CRM. With none of the three the emdash template's `/api/track` refuses the post (it\n * would otherwise accept it and lose the message), so `vitops emdash check-prerender` asks the\n * same question of the config before the first visitor finds out. One function so the build\n * check and the route cannot disagree.\n */\nexport function hasFormDelivery(\n ctx: Pick<NotifyContext, 'notifications' | 'locationEmails'>,\n crm?: unknown,\n): boolean {\n return Boolean(ctx.notifications?.email || ctx.locationEmails?.length || crm);\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * One line, already rendered both ways — a plain fact (`html` is just the\n * escaped `text`) or a link (`html` an anchor, `text` the bare href). Built at\n * the point each line is known rather than carried as a union and branched on\n * later: `describeEvent`'s lines are always plain `[label, value]` pairs (see\n * `Section` above, which `render.test.ts` destructures as such), and the one\n * line shape `renderEmail` adds itself — the \"Actions\" link — is turned into\n * this same rendered shape immediately, so the two renderers below share one\n * line format instead of branching on what kind of line each one is.\n */\ninterface RenderedLine {\n label: string;\n text: string;\n html: string;\n}\n\n/** A section as `renderEmail` assembles it — `describeEvent`'s output plus its own additions. */\ninterface RenderSection {\n heading: string;\n lines: RenderedLine[];\n}\n\nfunction factLine([label, value]: readonly [string, string]): RenderedLine {\n return { label, text: value, html: escapeHtml(value) };\n}\n\nfunction linkLine(label: string, href: string): RenderedLine {\n return { label, text: href, html: `<a href=\"${escapeHtml(href)}\">${escapeHtml(href)}</a>` };\n}\n\nfunction toRenderSection(section: Section): RenderSection {\n return { heading: section.heading, lines: section.lines.map(factLine) };\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n return identifySubmitter(formData).fullName;\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else if (event.type === 'email') {\n const email = event.email ?? 'unknown';\n subject = `[Email Click] Visitor emailing ${email}`;\n contact.push(['Email', email]);\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n if (event.phoneSource) contact.push(['Number shown', `${event.phoneSource} tracking number`]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(\n event: ConversionEvent,\n extra: { notes?: string[] | undefined; actionUrl?: string | undefined } = {},\n): RenderedMessage {\n const { subject, sections: eventSections } = describeEvent(event);\n\n // `{clickId}` is the only placeholder — an event has no durable id nothing\n // in this toolchain could look back up (there's no store), so interpolating\n // one would be decorative. Omitted entirely when there's no click ID to\n // interpolate, rather than emailing a link with a literal `{clickId}` in it.\n const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;\n const actionSection: RenderSection | null =\n extra.actionUrl && clickId\n ? {\n heading: 'Actions',\n lines: [\n linkLine(\n 'View in ad platform',\n extra.actionUrl.replace(/\\{clickId\\}/g, encodeURIComponent(clickId.value)),\n ),\n ],\n }\n : null;\n\n // Deliberately a channel-neutral `string[]` rather than a typed CRM result —\n // this module gains no dependency on `crm`, and the seam serves any future\n // sink the same way.\n const sections: RenderSection[] = [\n ...eventSections.map(toRenderSection),\n ...(actionSection ? [actionSection] : []),\n ...(extra.notes && extra.notes.length\n ? [{ heading: 'Delivery', lines: extra.notes.map((n) => factLine(['CRM', n])) }]\n : []),\n ];\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n (l) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(l.label)}</td>` +\n `<td style=\"padding:2px 0\">${l.html}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Google Ads offline conversion upload — the server-side counterpart to the\n * client-side `gtag('event', 'conversion', …)` call `gtagBootstrap`\n * (`@getvitops/utils/ads`) wires up. Reads `site.ads.google.reporting: 'server'`\n * and a matching `conversionActionId` (see that field's own docblock for why\n * it is a different identifier from `conversionLabel`, not an alternate\n * spelling of it) and calls `ConversionUploadService.UploadClickConversions`\n * instead of relying on a visitor's browser to fire the tag.\n *\n * Same reasoning as `@getvitops/utils/crm`'s executors: credentials are a\n * structurally-typed shape the consumer's route supplies from env vars, never\n * config, and a failed upload is a `GoogleAdsUploadResult` to log or fold into\n * the notification email — never an exception, and never something that fails\n * the conversion request it's attached to. A lost ad report is not a lost\n * lead; the visitor has already submitted.\n *\n * **Structurally typed against Google's documented REST shape, not verified\n * against a live Ads account** — no developer-token/MCC access exists\n * anywhere in this toolchain's own setup to test against (the same blocker\n * `vitops ads provision` is waiting on). Same caveat, and the same reason, as\n * `@getvitops/utils/crm`'s Jobber executor: wrapped so a wrong guess about the\n * exact request/response shape degrades to a stated failure reason rather\n * than breaking. Re-verify against `googleads.googleapis.com`'s current API\n * version and `ClickConversion` message shape before depending on this in\n * production, and update this docblock with what was found.\n */\nimport { errorDetail, googleAccessToken, googleHeaders } from '../google/token.ts';\nimport type { GoogleOAuth } from '../google/token.ts';\n\n/** Bumped independently of everything else here — the Ads API deprecates\n * versions on its own rolling schedule. Last checked against Google's public\n * docs at the time this file was written; not re-verified since. */\nconst API_VERSION = 'v17';\n\nexport interface GoogleAdsCredentials {\n /** Issued against a Google Ads manager (MCC) account — a Google review, not\n * a self-service toggle. Never config. */\n developerToken: string;\n oauth: GoogleOAuth;\n /** The MCC's own (dashes-or-not) customer id — required when `customerId`\n * in the upload input is a client account managed under it, which is the\n * common case for an agency running this on a client's behalf. */\n loginCustomerId?: string | undefined;\n}\n\nexport interface GoogleAdsUploadInput {\n /** Google Ads customer id, as shown in the UI (`\"123-456-7890\"`) or bare\n * digits — either works, dashes are stripped before the call. */\n customerId: string;\n /** A numeric conversion action id, or a full\n * `customers/…/conversionActions/…` resource name. */\n conversionActionId: string;\n /** The click id captured on `_ac` — `getPrimaryClickId()`'s result. */\n clickId: { param: string; value: string };\n /** Epoch ms — converted to the Ads API's own\n * `\"yyyy-MM-dd HH:mm:ss+00:00\"` format. */\n at: number;\n conversionValue?: number | undefined;\n currencyCode?: string | undefined;\n orderId?: string | undefined;\n}\n\n/** Never thrown — an Ads API outage or a malformed request both come back as\n * `ok: false`, the same \"structured result, not an exception\" rule every\n * other executor in this toolchain follows. */\nexport interface GoogleAdsUploadResult {\n ok: boolean;\n reason?: string | undefined;\n code?: string | undefined;\n}\n\nfunction digitsOnly(id: string): string {\n return id.replace(/\\D/g, '');\n}\n\n/** A bare numeric id becomes a full resource name under `customerId`; a value\n * already shaped like one (the escape hatch, matching `txtRecord`'s `=`\n * check in `@getvitops/utils/ads`) passes through unchanged. */\nexport function conversionActionResourceName(\n customerId: string,\n conversionActionId: string,\n): string {\n if (conversionActionId.includes('customers/')) return conversionActionId;\n return `customers/${digitsOnly(customerId)}/conversionActions/${conversionActionId}`;\n}\n\n/** The Ads API's documented click-conversion datetime format, always\n * rendered in UTC — there is no per-account timezone lookup here to do\n * better, and UTC is unambiguous. */\nexport function toGoogleConversionDateTime(atMs: number): string {\n const d = new Date(atMs);\n const pad = (n: number): string => String(n).padStart(2, '0');\n return (\n `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +\n `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+00:00`\n );\n}\n\n/** The request field a click id param maps to — the API takes `gclid`/\n * `gbraid`/`wbraid` as distinct fields, not one generic \"click id\" field.\n * Anything else this toolchain's `PLATFORM_PARAMS` maps to Google (today,\n * only these three) falls back to `gclid`. */\nfunction clickIdField(param: string): 'gclid' | 'gbraid' | 'wbraid' {\n if (param === 'gbraid' || param === 'wbraid') return param;\n return 'gclid';\n}\n\n/**\n * Upload one click conversion via `ConversionUploadService.UploadClickConversions`.\n *\n * `partialFailure: true` is always sent, and a 200 response carrying\n * `partialFailureError` is treated as a failure, not a success — a single-\n * conversion batch makes \"partial\" and \"total\" failure the same case, and\n * Google Ads API responses are 200 for a partial failure by design (silently\n * treating that as success is exactly the trap `@getvitops/utils/crm`'s\n * Jobber executor documents for its own GraphQL transport).\n */\nexport async function uploadClickConversion(\n input: GoogleAdsUploadInput,\n credentials: GoogleAdsCredentials,\n fetchImpl: typeof fetch = fetch,\n): Promise<GoogleAdsUploadResult> {\n let token: string;\n try {\n token = await googleAccessToken({ kind: 'oauth', oauth: credentials.oauth }, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const customerId = digitsOnly(input.customerId);\n const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadClickConversions`;\n\n const body = {\n conversions: [\n {\n conversionAction: conversionActionResourceName(customerId, input.conversionActionId),\n [clickIdField(input.clickId.param)]: input.clickId.value,\n conversionDateTime: toGoogleConversionDateTime(input.at),\n ...(input.conversionValue != null ? { conversionValue: input.conversionValue } : {}),\n ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),\n ...(input.orderId ? { orderId: input.orderId } : {}),\n },\n ],\n partialFailure: true,\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: googleHeaders(token, {\n 'developer-token': credentials.developerToken,\n 'content-type': 'application/json',\n ...(credentials.loginCustomerId\n ? { 'login-customer-id': digitsOnly(credentials.loginCustomerId) }\n : {}),\n }),\n body: JSON.stringify(body),\n });\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (!res.ok) {\n return {\n ok: false,\n code: String(res.status),\n reason: await errorDetail(res, (status) =>\n status === 401\n ? 'Google Ads rejected the access token.'\n : status === 403\n ? 'Google Ads refused the request — check the developer token is approved and `login-customer-id` is set for an MCC-managed account.'\n : undefined,\n ),\n };\n }\n\n const json = (await res.json().catch(() => ({}))) as {\n partialFailureError?: { message?: string };\n };\n if (json.partialFailureError) {\n return {\n ok: false,\n reason: `Google Ads rejected the conversion: ${json.partialFailureError.message ?? 'no detail given'}`,\n };\n }\n\n return { ok: true };\n}\n\n/** A one-line delivery note, matching `describeCrmResult`'s shape — what\n * `conversion.ts` folds into the notification email's \"Delivery\" section. */\nexport function describeGoogleAdsOfflineResult(\n result: GoogleAdsUploadResult | null,\n): string | null {\n if (!result) return null;\n if (result.ok) return 'Reported to Google Ads (server-side)';\n return `⚠ Not reported to Google Ads: ${result.reason ?? 'unknown reason'}`;\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * `google-ads.ts` is a fourth file, added later, that does **not** follow the\n * three-file shape above: it isn't a `NotificationsConfig` channel `notify()`\n * plans and dispatches, it's a separate delivery keyed to `site.ads.google`,\n * wired in by `createConversionRoute`'s own `googleAdsOffline` option and\n * folded into the email's \"Delivery\" section exactly like `@getvitops/utils/\n * crm`'s `CrmResult` — see that module's `describeCrmResult` for the\n * precedent this follows (`describeGoogleAdsOfflineResult` here).\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n extra: { notes?: string[] | undefined } = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event, { ...extra, actionUrl: ctx.notifications?.actionUrl });\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n hasFormDelivery,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport {\n conversionActionResourceName,\n describeGoogleAdsOfflineResult,\n toGoogleConversionDateTime,\n uploadClickConversion,\n} from './google-ads.ts';\nexport type {\n GoogleAdsCredentials,\n GoogleAdsUploadInput,\n GoogleAdsUploadResult,\n} from './google-ads.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// src/ads/types.ts
|
|
2
|
+
/**
|
|
3
|
+
* A bare string is back-compat shorthand for `{ call: label }` — the only
|
|
4
|
+
* action that existed before this was a map. Absent input normalises to `{}`.
|
|
5
|
+
*/
|
|
6
|
+
function normalizeConversionLabels(label) {
|
|
7
|
+
if (label == null) return {};
|
|
8
|
+
return typeof label === "string" ? { call: label } : label;
|
|
9
|
+
}
|
|
10
|
+
/** Same back-compat shorthand as `normalizeConversionLabels`, for ids. */
|
|
11
|
+
function normalizeConversionActionIds(id) {
|
|
12
|
+
if (id == null) return {};
|
|
13
|
+
return typeof id === "string" ? { call: id } : id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/ads/providers.ts
|
|
17
|
+
/**
|
|
18
|
+
* What each ad platform actually lets a build tool do — the table every `vitops ads`
|
|
19
|
+
* subcommand reads.
|
|
20
|
+
*
|
|
21
|
+
* Written the way the indexing module was written: start from what the platforms
|
|
22
|
+
* accept, because it is much narrower than it looks, and record the *absence* of a
|
|
23
|
+
* path as a fact rather than leaving it as a gap.
|
|
24
|
+
*
|
|
25
|
+
* - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,
|
|
26
|
+
* Snapchat). That record is the one thing here that can be created for you.
|
|
27
|
+
* - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,
|
|
28
|
+
* Microsoft Ads). Linking is the tag and the account id, nothing else. Those
|
|
29
|
+
* resolve to a skip *plus* a named reason — a silent omission would read as
|
|
30
|
+
* "already done".
|
|
31
|
+
* - **No platform Marketing API is called.** Meta's Business Management
|
|
32
|
+
* `verify_domain` needs a system-user token; the Google Ads API needs an
|
|
33
|
+
* approved developer token and puts the consumer's own account on the line.
|
|
34
|
+
* That is the same reasoning that keeps the Google Indexing API unwired: a
|
|
35
|
+
* toolchain other people install must not ship a documented path to a terms
|
|
36
|
+
* violation. The final "Verify" click stays a reminder.
|
|
37
|
+
*
|
|
38
|
+
* The verification token is **not a secret** — it is published in DNS, and the
|
|
39
|
+
* platform fetching it back is the ownership proof, exactly like the IndexNow key.
|
|
40
|
+
* So it lives in the config while credentials stay in the environment.
|
|
41
|
+
*
|
|
42
|
+
* Each tag is the platform's documented bootstrap reduced to its queue stub, with
|
|
43
|
+
* the remote library on `data-src` for the consent gate to attach after the
|
|
44
|
+
* bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body
|
|
45
|
+
* first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a
|
|
46
|
+
* snippet that creates its own `<script>`; those declare no `src`, and their body
|
|
47
|
+
* stays inert until activation, so an undecided visitor still issues no request.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs.
|
|
51
|
+
*
|
|
52
|
+
* `gtag('config', id)` only loads the tag — it never reports a conversion. When
|
|
53
|
+
* `entry.conversionLabel` names one or more actions, this also exposes a
|
|
54
|
+
* `{action: 'id/label'}` map on `window.vitopsAdsConversions.google` — one entry
|
|
55
|
+
* per configured action, so a call, an email click and a form submission each
|
|
56
|
+
* carry their own `send_to` target instead of all three sharing one (that
|
|
57
|
+
* sharing was a real bug: a call and an email click used to report as the same
|
|
58
|
+
* Google Ads conversion action, indistinguishable in the account). Bootstrap
|
|
59
|
+
* time only loads the tag; `scripts/tracking.ts`'s click handler and
|
|
60
|
+
* `vitopsFormConversion()` each read their own action's key and fire
|
|
61
|
+
* `gtag('event', 'conversion', …)` at the moment of the actual interaction —
|
|
62
|
+
* bootstrap-time firing would count every pageview as a conversion.
|
|
63
|
+
*
|
|
64
|
+
* `entry` is trusted here — `resolveAds()` validates every configured label
|
|
65
|
+
* against `SAFE_ID` before it reaches this function, the same guard `id`
|
|
66
|
+
* already gets.
|
|
67
|
+
*
|
|
68
|
+
* **`entry.reporting === 'server'` suppresses an action here, never
|
|
69
|
+
* silently.** An action fires client-side unless reporting is `'server'`
|
|
70
|
+
* *and* that same action has a `conversionActionId` configured — so turning
|
|
71
|
+
* reporting to `'server'` before the id is filled in leaves the action firing
|
|
72
|
+
* client-side rather than dropping the conversion. Once both are set, the
|
|
73
|
+
* server-side upload (`@getvitops/utils/notify`'s `uploadClickConversion`,
|
|
74
|
+
* wired in by `createConversionRoute`'s `googleAdsOffline` option) is the one
|
|
75
|
+
* place that action reports from — this is what avoids double-counting.
|
|
76
|
+
*/
|
|
77
|
+
const gtagBootstrap = (id, entry) => {
|
|
78
|
+
const labels = normalizeConversionLabels(entry.conversionLabel);
|
|
79
|
+
const actionIds = normalizeConversionActionIds(entry.conversionActionId);
|
|
80
|
+
const actions = Object.entries(labels).filter((pair) => !!pair[1] && !(entry.reporting === "server" && !!actionIds[pair[0]]));
|
|
81
|
+
return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}');` + callTrackingConfig(id, entry) + (actions.length ? `window.vitopsAdsConversions=window.vitopsAdsConversions||{};window.vitopsAdsConversions.google=${JSON.stringify(Object.fromEntries(actions.map(([action, label]) => [action, `${id}/${label}`])))};` : "");
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Google's website call conversions: `gtag('config', 'AW-…/<label>', { phone_conversion_number })`.
|
|
85
|
+
*
|
|
86
|
+
* Google fetches a forwarding number for a visitor who arrived from a Google ad and
|
|
87
|
+
* replaces the displayed number's **text** — never the `tel:` href, which Google says
|
|
88
|
+
* needs the callback. The callback hands the result to `vitopsSwapPhone`
|
|
89
|
+
* (`@getvitops/astro`'s tracking script) through a queue, because this inline body and
|
|
90
|
+
* that module load in no guaranteed order: a call that lands first is replayed when the
|
|
91
|
+
* script defines the function, rather than lost.
|
|
92
|
+
*
|
|
93
|
+
* `entry.callTracking` is trusted — `resolveAds()` checks the label against `SAFE_ID`
|
|
94
|
+
* and the number against a phone pattern before it reaches here.
|
|
95
|
+
*/
|
|
96
|
+
const callTrackingConfig = (id, entry) => {
|
|
97
|
+
const call = entry.callTracking;
|
|
98
|
+
if (!call) return "";
|
|
99
|
+
const options = call.cache === false ? ",phone_conversion_options:{cache:false}" : "";
|
|
100
|
+
return `window.vitopsPhoneSwaps=window.vitopsPhoneSwaps||[];gtag('config','${id}/${call.label}',{phone_conversion_number:${JSON.stringify(call.number)},phone_conversion_callback:function(f,m){var a=[f,m,${JSON.stringify(call.number)}];window.vitopsSwapPhone?window.vitopsSwapPhone.apply(null,a):window.vitopsPhoneSwaps.push(a)}${options}});`;
|
|
101
|
+
};
|
|
102
|
+
/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */
|
|
103
|
+
const queueStub = (global, methods) => `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};window.${global}.methods=${methods};`;
|
|
104
|
+
const AD_PLATFORMS = {
|
|
105
|
+
google: {
|
|
106
|
+
name: "Google Ads",
|
|
107
|
+
operatorCountry: "the United States",
|
|
108
|
+
clickIdParams: [
|
|
109
|
+
"gclid",
|
|
110
|
+
"gbraid",
|
|
111
|
+
"wbraid"
|
|
112
|
+
],
|
|
113
|
+
websiteCallTracking: true,
|
|
114
|
+
consoleUrl: "https://ads.google.com/",
|
|
115
|
+
verification: {
|
|
116
|
+
method: "none",
|
|
117
|
+
reason: "Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)"
|
|
118
|
+
},
|
|
119
|
+
tag: {
|
|
120
|
+
needs: "pixelId",
|
|
121
|
+
src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
|
|
122
|
+
bootstrap: gtagBootstrap,
|
|
123
|
+
cookies: [
|
|
124
|
+
"_gcl_au",
|
|
125
|
+
"_gcl_aw",
|
|
126
|
+
"_gcl_dc",
|
|
127
|
+
"_gac_*"
|
|
128
|
+
],
|
|
129
|
+
privacyUrl: "https://policies.google.com/privacy",
|
|
130
|
+
optOut: "https://adssettings.google.com/"
|
|
131
|
+
},
|
|
132
|
+
manualSteps: (e) => [
|
|
133
|
+
...Object.keys(normalizeConversionLabels(e.conversionLabel)).length ? ["confirm each conversion action's category in Google Ads (call → \"Phone call lead\", email → \"Contact\", form → \"Submit lead form\"); the tag cannot set it, and a wrong one reports under the wrong goal"] : ["create a conversion action in Google Ads and record its label as `conversionLabel`"],
|
|
134
|
+
"link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions",
|
|
135
|
+
...e.callTracking ? [
|
|
136
|
+
"in Google Ads, create a conversion action of type \"Calls from a phone number on your website\", set its minimum call length, and make either it or the `conversionLabel.call` (tap) action secondary so one call is not counted twice",
|
|
137
|
+
"verify the domain through Search Console, not the tag — Google cannot scan a page whose number is swapped (a tag installed through Google Tag Manager can't verify it either)",
|
|
138
|
+
"test with `#google-wcc-debug` appended to a landing URL after granting marketing consent"
|
|
139
|
+
] : []
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
meta: {
|
|
143
|
+
name: "Meta",
|
|
144
|
+
operatorCountry: "the United States",
|
|
145
|
+
clickIdParams: ["fbclid"],
|
|
146
|
+
websiteCallTracking: false,
|
|
147
|
+
consoleUrl: "https://business.facebook.com/settings/owned-domains",
|
|
148
|
+
verification: {
|
|
149
|
+
method: "dns-txt",
|
|
150
|
+
txtPrefix: "facebook-domain-verification=",
|
|
151
|
+
where: "Business settings → Brand safety → Domains → Add → DNS verification"
|
|
152
|
+
},
|
|
153
|
+
tag: {
|
|
154
|
+
needs: "pixelId",
|
|
155
|
+
src: () => "https://connect.facebook.net/en_US/fbevents.js",
|
|
156
|
+
bootstrap: (id) => `${queueStub("fbq", `['init','track','trackCustom','consent']`)}fbq('init','${id}');fbq('track','PageView');`,
|
|
157
|
+
cookies: ["_fbp", "_fbc"],
|
|
158
|
+
privacyUrl: "https://www.facebook.com/privacy/policy/",
|
|
159
|
+
optOut: "https://www.facebook.com/adpreferences/ad_settings"
|
|
160
|
+
},
|
|
161
|
+
manualSteps: () => ["click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called"]
|
|
162
|
+
},
|
|
163
|
+
linkedin: {
|
|
164
|
+
name: "LinkedIn",
|
|
165
|
+
operatorCountry: "the United States",
|
|
166
|
+
clickIdParams: ["li_fat_id"],
|
|
167
|
+
websiteCallTracking: false,
|
|
168
|
+
consoleUrl: "https://www.linkedin.com/campaignmanager/",
|
|
169
|
+
verification: {
|
|
170
|
+
method: "none",
|
|
171
|
+
reason: "LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)"
|
|
172
|
+
},
|
|
173
|
+
tag: {
|
|
174
|
+
needs: "accountId",
|
|
175
|
+
src: () => "https://snap.licdn.com/li.lms-analytics/insight.min.js",
|
|
176
|
+
bootstrap: (id) => `window._linkedin_partner_id='${id}';window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];window._linkedin_data_partner_ids.push('${id}');`,
|
|
177
|
+
cookies: [
|
|
178
|
+
"li_sugr",
|
|
179
|
+
"li_gc",
|
|
180
|
+
"bcookie",
|
|
181
|
+
"lidc",
|
|
182
|
+
"UserMatchHistory"
|
|
183
|
+
],
|
|
184
|
+
privacyUrl: "https://www.linkedin.com/legal/privacy-policy",
|
|
185
|
+
optOut: "https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out"
|
|
186
|
+
},
|
|
187
|
+
manualSteps: () => ["add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions"]
|
|
188
|
+
},
|
|
189
|
+
reddit: {
|
|
190
|
+
name: "Reddit",
|
|
191
|
+
operatorCountry: "the United States",
|
|
192
|
+
clickIdParams: ["rdt_cid"],
|
|
193
|
+
websiteCallTracking: false,
|
|
194
|
+
consoleUrl: "https://ads.reddit.com/",
|
|
195
|
+
verification: {
|
|
196
|
+
method: "none",
|
|
197
|
+
reason: "Reddit Ads has no domain verification — the Reddit Pixel is the link."
|
|
198
|
+
},
|
|
199
|
+
tag: {
|
|
200
|
+
needs: "pixelId",
|
|
201
|
+
src: () => "https://www.redditstatic.com/ads/pixel.js",
|
|
202
|
+
bootstrap: (id) => `${queueStub("rdt", `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,
|
|
203
|
+
cookies: ["_rdt_uuid"],
|
|
204
|
+
privacyUrl: "https://www.reddit.com/policies/privacy-policy"
|
|
205
|
+
},
|
|
206
|
+
manualSteps: () => []
|
|
207
|
+
},
|
|
208
|
+
tiktok: {
|
|
209
|
+
name: "TikTok",
|
|
210
|
+
operatorCountry: "Singapore",
|
|
211
|
+
clickIdParams: ["ttclid"],
|
|
212
|
+
websiteCallTracking: false,
|
|
213
|
+
consoleUrl: "https://ads.tiktok.com/",
|
|
214
|
+
verification: {
|
|
215
|
+
method: "dns-txt",
|
|
216
|
+
txtPrefix: "tiktok-developers-site-verification=",
|
|
217
|
+
where: "Ads Manager → Assets → Events → Web events → Verify domain → DNS"
|
|
218
|
+
},
|
|
219
|
+
tag: {
|
|
220
|
+
needs: "pixelId",
|
|
221
|
+
src: (id) => `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,
|
|
222
|
+
bootstrap: (id) => `window.TiktokAnalyticsObject='ttq';${queueStub("ttq", `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}ttq('init','${id}');ttq('page');`,
|
|
223
|
+
cookies: ["_ttp"],
|
|
224
|
+
privacyUrl: "https://www.tiktok.com/legal/privacy-policy"
|
|
225
|
+
},
|
|
226
|
+
manualSteps: () => []
|
|
227
|
+
},
|
|
228
|
+
microsoft: {
|
|
229
|
+
name: "Microsoft Ads",
|
|
230
|
+
operatorCountry: "the United States",
|
|
231
|
+
clickIdParams: ["msclkid"],
|
|
232
|
+
websiteCallTracking: false,
|
|
233
|
+
consoleUrl: "https://ads.microsoft.com/",
|
|
234
|
+
verification: {
|
|
235
|
+
method: "none",
|
|
236
|
+
reason: "Microsoft Ads has no domain verification — the UET tag is the link."
|
|
237
|
+
},
|
|
238
|
+
tag: {
|
|
239
|
+
needs: "pixelId",
|
|
240
|
+
bootstrap: (id) => `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,
|
|
241
|
+
cookies: [
|
|
242
|
+
"_uetsid",
|
|
243
|
+
"_uetvid",
|
|
244
|
+
"MUID"
|
|
245
|
+
],
|
|
246
|
+
privacyUrl: "https://privacy.microsoft.com/privacystatement",
|
|
247
|
+
optOut: "https://account.microsoft.com/privacy/ad-settings"
|
|
248
|
+
},
|
|
249
|
+
manualSteps: () => []
|
|
250
|
+
},
|
|
251
|
+
pinterest: {
|
|
252
|
+
name: "Pinterest",
|
|
253
|
+
operatorCountry: "the United States",
|
|
254
|
+
clickIdParams: ["epik"],
|
|
255
|
+
websiteCallTracking: false,
|
|
256
|
+
consoleUrl: "https://ads.pinterest.com/",
|
|
257
|
+
verification: {
|
|
258
|
+
method: "dns-txt",
|
|
259
|
+
txtPrefix: "pinterest-site-verification=",
|
|
260
|
+
where: "Business hub → Claim → Websites → Claim → Add TXT record"
|
|
261
|
+
},
|
|
262
|
+
tag: {
|
|
263
|
+
needs: "pixelId",
|
|
264
|
+
bootstrap: (id) => `${queueStub("pintrk", `['load','page','track']`)}!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();pintrk('load','${id}');pintrk('page');`,
|
|
265
|
+
cookies: [
|
|
266
|
+
"_pinterest_ct_ua",
|
|
267
|
+
"_pin_unauth",
|
|
268
|
+
"_epik"
|
|
269
|
+
],
|
|
270
|
+
privacyUrl: "https://policy.pinterest.com/privacy-policy"
|
|
271
|
+
},
|
|
272
|
+
manualSteps: () => ["confirm the claim in Business hub → Claim once the TXT record has propagated"]
|
|
273
|
+
},
|
|
274
|
+
snapchat: {
|
|
275
|
+
name: "Snapchat",
|
|
276
|
+
operatorCountry: "the United States",
|
|
277
|
+
clickIdParams: ["ScCid"],
|
|
278
|
+
websiteCallTracking: false,
|
|
279
|
+
consoleUrl: "https://ads.snapchat.com/",
|
|
280
|
+
verification: {
|
|
281
|
+
method: "dns-txt",
|
|
282
|
+
txtPrefix: "snap-domain-verification=",
|
|
283
|
+
where: "Business Manager → Public Profiles / Domains → Verify domain → DNS"
|
|
284
|
+
},
|
|
285
|
+
tag: {
|
|
286
|
+
needs: "pixelId",
|
|
287
|
+
src: () => "https://sc-static.net/scevent.min.js",
|
|
288
|
+
bootstrap: (id) => `${queueStub("snaptr", `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,
|
|
289
|
+
cookies: [
|
|
290
|
+
"_scid",
|
|
291
|
+
"_scid_r",
|
|
292
|
+
"sc_at"
|
|
293
|
+
],
|
|
294
|
+
privacyUrl: "https://snap.com/privacy/privacy-policy"
|
|
295
|
+
},
|
|
296
|
+
manualSteps: () => []
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
/** Every provider, in table order — the one place iteration order is decided. */
|
|
300
|
+
const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS);
|
|
301
|
+
/** Whether a string names a known provider (for CLI arg validation). */
|
|
302
|
+
const isAdProvider = (value) => value in AD_PLATFORMS;
|
|
303
|
+
/**
|
|
304
|
+
* The DNS TXT record content for a token.
|
|
305
|
+
*
|
|
306
|
+
* A value already containing `=` is taken as the whole record and left alone. That
|
|
307
|
+
* is the same escape hatch an icon name containing `:` gets: a platform can change
|
|
308
|
+
* its prefix spelling without waiting on a toolchain release, and an operator who
|
|
309
|
+
* pasted the full record from the UI gets what they meant rather than a
|
|
310
|
+
* double-prefixed record that verifies against nothing.
|
|
311
|
+
*/
|
|
312
|
+
function txtRecord(provider, token) {
|
|
313
|
+
const trimmed = token.trim();
|
|
314
|
+
if (trimmed.includes("=")) return trimmed;
|
|
315
|
+
return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ""}${trimmed}`;
|
|
316
|
+
}
|
|
317
|
+
/** The tag id for an entry, or undefined when the field it needs is unset. */
|
|
318
|
+
function tagId(entry) {
|
|
319
|
+
return entry[AD_PLATFORMS[entry.provider].tag.needs];
|
|
320
|
+
}
|
|
321
|
+
/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */
|
|
322
|
+
const categoryOf = (entry) => entry.category ?? "marketing";
|
|
323
|
+
const escapeAttr = (value) => value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
324
|
+
/**
|
|
325
|
+
* One provider's tag, rendered as a **consent-gated, inert** `<script>`.
|
|
326
|
+
*
|
|
327
|
+
* `type="text/plain"` with the URL on `data-src` is what makes the gate real: the
|
|
328
|
+
* browser neither parses the body nor fetches the library, so an undecided
|
|
329
|
+
* visitor's page issues no third-party request. Never give one of these a live
|
|
330
|
+
* `src` — a gate that instead asks a loaded tracker not to track is a promise.
|
|
331
|
+
*
|
|
332
|
+
* Returns null when the entry has no tag id, so a caller can report the gap rather
|
|
333
|
+
* than emit a snippet that initialises `undefined`.
|
|
334
|
+
*/
|
|
335
|
+
function renderTag(entry, strategy = "idle") {
|
|
336
|
+
const platform = AD_PLATFORMS[entry.provider];
|
|
337
|
+
const id = tagId(entry);
|
|
338
|
+
if (!id) return null;
|
|
339
|
+
const attrs = [
|
|
340
|
+
"type=\"text/plain\"",
|
|
341
|
+
"data-vitops-tag",
|
|
342
|
+
`data-consent="${categoryOf(entry)}"`,
|
|
343
|
+
`data-strategy="${strategy}"`,
|
|
344
|
+
...platform.tag.src ? [`data-src="${escapeAttr(platform.tag.src(id))}"`] : [],
|
|
345
|
+
`data-consent-cookies="${escapeAttr(platform.tag.cookies.join(","))}"`
|
|
346
|
+
];
|
|
347
|
+
return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\n<script ${attrs.join(" ")}>${platform.tag.bootstrap(id, entry)}<\/script>`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export { renderTag as a, normalizeConversionActionIds as c, isAdProvider as i, normalizeConversionLabels as l, AD_PROVIDER_KEYS as n, tagId as o, categoryOf as r, txtRecord as s, AD_PLATFORMS as t };
|
|
351
|
+
//# sourceMappingURL=providers-CEHw-j4v.mjs.map
|