@endora-commerce/mod-google-tag-manager 0.100.0
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/backend/index.d.ts +62 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +70 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.storefront.d.ts +16 -0
- package/dist/backend/routes.storefront.d.ts.map +1 -0
- package/dist/backend/routes.storefront.js +40 -0
- package/dist/backend/routes.storefront.js.map +1 -0
- package/dist/backend/services/gtm-config.service.d.ts +27 -0
- package/dist/backend/services/gtm-config.service.d.ts.map +1 -0
- package/dist/backend/services/gtm-config.service.js +65 -0
- package/dist/backend/services/gtm-config.service.js.map +1 -0
- package/dist/backend/services/sgtm-client.d.ts +46 -0
- package/dist/backend/services/sgtm-client.d.ts.map +1 -0
- package/dist/backend/services/sgtm-client.js +69 -0
- package/dist/backend/services/sgtm-client.js.map +1 -0
- package/dist/backend/services/ss-relay-queue.d.ts +46 -0
- package/dist/backend/services/ss-relay-queue.d.ts.map +1 -0
- package/dist/backend/services/ss-relay-queue.js +29 -0
- package/dist/backend/services/ss-relay-queue.js.map +1 -0
- package/dist/backend/services/ss-relay.service.d.ts +24 -0
- package/dist/backend/services/ss-relay.service.d.ts.map +1 -0
- package/dist/backend/services/ss-relay.service.js +77 -0
- package/dist/backend/services/ss-relay.service.js.map +1 -0
- package/dist/manifest.d.ts +194 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +117 -0
- package/dist/manifest.js.map +1 -0
- package/docs/google-tag-manager.md +228 -0
- package/i18n/en.json +4 -0
- package/i18n/pl.json +4 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Endora sp. z o.o. and the Endora Commerce contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!-- Generated by `pnpm --filter backend run manifests:generate`. Delete this line to take the file over; a README without it is never regenerated. -->
|
|
2
|
+
|
|
3
|
+
# @endora-commerce/mod-google-tag-manager
|
|
4
|
+
|
|
5
|
+
Google Tag Manager integration: per-sales-channel container injection behind the storefront consent decision, a documented commerce dataLayer vocabulary, and an optional server-side tagging relay.
|
|
6
|
+
|
|
7
|
+
## What this is
|
|
8
|
+
|
|
9
|
+
An **Endora Commerce module package**. Its module id is `google_tag_manager` — the identity of record in the platform's registries, in its migrations and in its setting, permission and translation codes.
|
|
10
|
+
|
|
11
|
+
A module is not imported by application code. The platform discovers the extension packages an instance has installed, reads the manifest on the root subpath and composes the module from it, so installing this package and starting the instance is the whole integration.
|
|
12
|
+
|
|
13
|
+
## Entry points
|
|
14
|
+
|
|
15
|
+
| Import | Contents |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `@endora-commerce/mod-google-tag-manager` | the module manifest — its id, version, dependencies, settings and activation |
|
|
18
|
+
| `@endora-commerce/mod-google-tag-manager/backend` | the composition root the platform calls, with the entities, services, routes and workers it registers |
|
|
19
|
+
|
|
20
|
+
## Depends on
|
|
21
|
+
|
|
22
|
+
Everything below is a **peer** dependency, so the application resolves exactly one copy of each — two copies of a host or of React are a runtime failure rather than a type error. An entry marked *optional* is needed only by the layers that use it.
|
|
23
|
+
|
|
24
|
+
**Endora packages**
|
|
25
|
+
|
|
26
|
+
- `@endora-commerce/contracts`
|
|
27
|
+
- `@endora-commerce/platform`
|
|
28
|
+
|
|
29
|
+
**Third-party**
|
|
30
|
+
|
|
31
|
+
- `bullmq` ^5
|
|
32
|
+
- `fastify` ^5
|
|
33
|
+
- `ioredis` ^5
|
|
34
|
+
- `zod` ^4
|
|
35
|
+
|
|
36
|
+
## What the tarball carries
|
|
37
|
+
|
|
38
|
+
- `dist/` — the compiled JavaScript and its type declarations
|
|
39
|
+
- `i18n/` — the translation bundles this package ships (`en`, `pl`), loaded by the platform
|
|
40
|
+
- `docs/` — the package’s own pages in the Endora Commerce documentation site
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add @endora-commerce/mod-google-tag-manager
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Licence
|
|
49
|
+
|
|
50
|
+
MIT — the text is in `LICENSE`, beside this file.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import type { ModuleContext } from '@endora-commerce/platform/kernel';
|
|
3
|
+
import type { SettingsReadPort } from '@endora-commerce/platform/kernel';
|
|
4
|
+
import { StorefrontRevalidator } from '@endora-commerce/platform/http';
|
|
5
|
+
import { GtmConfigService } from './services/gtm-config.service.js';
|
|
6
|
+
import { makeEnqueuer } from './services/ss-relay.service.js';
|
|
7
|
+
/**
|
|
8
|
+
* `google_tag_manager` — the third of the four ad modules whose subscription
|
|
9
|
+
* outlived its gate (feature 072, wave 2, T103).
|
|
10
|
+
*
|
|
11
|
+
* Same shape as `meta_ads` and `linkedin_ads`: routes wrapped in
|
|
12
|
+
* `defineModuleRoutes`, but `onSettingChanged` arriving from a root as a bare
|
|
13
|
+
* `eventBus.on('settings.value_changed', …)`, so a switched-off module still
|
|
14
|
+
* answered every `google_tag_manager.*` change with an outbound storefront
|
|
15
|
+
* revalidation. `ctx.subscribe` is `subscribeForModule`, so it stops with the
|
|
16
|
+
* module now.
|
|
17
|
+
*
|
|
18
|
+
* **`moduleQueueRedis` is a separate name from `redis`, on purpose.** This
|
|
19
|
+
* module builds a BullMQ producer queue when it has a Redis connection, and the
|
|
20
|
+
* harness deliberately gives it none — `test-server.ts` says why: "a BullMQ
|
|
21
|
+
* queue built per `setupBackendServer()` is never closed, and this harness is
|
|
22
|
+
* constructed once per test file inside a single fork", so `/collect` degrades
|
|
23
|
+
* to 503 there and is contract-tested against its own bare instance instead.
|
|
24
|
+
* `redis` *is* registered in the harness container, so resolving that would
|
|
25
|
+
* have silently handed this module a connection the harness spent a comment
|
|
26
|
+
* explaining it must not have. A distinct name lets a composition say "no queue
|
|
27
|
+
* here" instead of leaving it to a missing option.
|
|
28
|
+
*
|
|
29
|
+
* The worker is registered through `ctx.worker`, which is `defineModuleWorker`
|
|
30
|
+
* — so the consumer stops with the module too, not only the producer.
|
|
31
|
+
*/
|
|
32
|
+
interface GtmServices {
|
|
33
|
+
readonly revalidator: StorefrontRevalidator;
|
|
34
|
+
readonly configService: GtmConfigService;
|
|
35
|
+
readonly invalidateConfig: () => void;
|
|
36
|
+
readonly enqueueRelay: ReturnType<typeof makeEnqueuer> | undefined;
|
|
37
|
+
}
|
|
38
|
+
export interface GoogleTagManagerCradle {
|
|
39
|
+
readonly settingsReadPort: SettingsReadPort;
|
|
40
|
+
/**
|
|
41
|
+
* The connection this module may build its relay queue on. Absent in a
|
|
42
|
+
* composition that does not want a queue — which is the harness, and any
|
|
43
|
+
* deployment running without Redis.
|
|
44
|
+
*/
|
|
45
|
+
readonly moduleQueueRedis: Redis | undefined;
|
|
46
|
+
readonly googleTagManagerServices: GtmServices;
|
|
47
|
+
}
|
|
48
|
+
export declare function registerModule(ctx: ModuleContext): void;
|
|
49
|
+
/**
|
|
50
|
+
* The module owns no persisted entity class, so the array is empty, on the `./backend` subpath, as one
|
|
51
|
+
* array and **no named class export** (D-168).
|
|
52
|
+
*
|
|
53
|
+
* This is the shape the platform reads when the package is *installed*: the
|
|
54
|
+
* boot-time loader (`src/packages/package-runtime.ts`, `exported['entities']`)
|
|
55
|
+
* and the static declaration reader (`scripts/lib/package-declarations.ts`),
|
|
56
|
+
* which is the third source of `check:module-boundary`'s `table→owner` map and
|
|
57
|
+
* the package pass of `check-entity-tenant-classification`. A missing array is
|
|
58
|
+
* answered with `[]` — zero entities registered, no error anywhere.
|
|
59
|
+
*/
|
|
60
|
+
export declare const entities: readonly never[];
|
|
61
|
+
export {};
|
|
62
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/backend/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AAEtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAEpE,OAAO,EAAE,YAAY,EAAiB,MAAM,gCAAgC,CAAC;AAI7E;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,UAAU,WAAW;IACnB,QAAQ,CAAC,WAAW,EAAE,qBAAqB,CAAC;IAC5C,QAAQ,CAAC,aAAa,EAAE,gBAAgB,CAAC;IACzC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IACtC,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,GAAG,SAAS,CAAC;CACpE;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,gBAAgB,EAAE,gBAAgB,CAAC;IAC5C;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,KAAK,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,wBAAwB,EAAE,WAAW,CAAC;CAChD;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA6DvD;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,EAAE,SAAS,KAAK,EAAO,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { lazyPort } from '@endora-commerce/platform/kernel';
|
|
2
|
+
import { StorefrontRevalidator } from '@endora-commerce/platform/http';
|
|
3
|
+
import { GtmConfigService } from './services/gtm-config.service.js';
|
|
4
|
+
import { createGtmRelayQueue, createGtmRelayWorker } from './services/ss-relay-queue.js';
|
|
5
|
+
import { makeEnqueuer, makeProcessor } from './services/ss-relay.service.js';
|
|
6
|
+
import { SgtmClient } from './services/sgtm-client.js';
|
|
7
|
+
import { registerGoogleTagManagerStorefrontRoutes } from './routes.storefront.js';
|
|
8
|
+
export function registerModule(ctx) {
|
|
9
|
+
ctx.di.register({
|
|
10
|
+
googleTagManagerServices: ctx
|
|
11
|
+
.asFunction(({ moduleQueueRedis }) => {
|
|
12
|
+
const revalidator = new StorefrontRevalidator({
|
|
13
|
+
baseUrl: process.env['STOREFRONT_BASE_URL'],
|
|
14
|
+
secret: process.env['REVALIDATE_SECRET'],
|
|
15
|
+
});
|
|
16
|
+
const invalidateConfig = () => {
|
|
17
|
+
void revalidator.revalidate(['gtm:config']);
|
|
18
|
+
};
|
|
19
|
+
const configService = new GtmConfigService(lazyPort(ctx, 'settingsReadPort'));
|
|
20
|
+
// Producer side: the API needs it to enqueue whether or not this
|
|
21
|
+
// process also runs the consumer.
|
|
22
|
+
const relayQueue = moduleQueueRedis
|
|
23
|
+
? createGtmRelayQueue(moduleQueueRedis)
|
|
24
|
+
: undefined;
|
|
25
|
+
return {
|
|
26
|
+
revalidator,
|
|
27
|
+
configService,
|
|
28
|
+
invalidateConfig,
|
|
29
|
+
enqueueRelay: relayQueue ? makeEnqueuer(relayQueue) : undefined,
|
|
30
|
+
};
|
|
31
|
+
})
|
|
32
|
+
.singleton(),
|
|
33
|
+
});
|
|
34
|
+
// An operator's edit propagates immediately rather than after the 300 s TTL
|
|
35
|
+
// (FR-009) — while the module is on.
|
|
36
|
+
ctx.subscribe('settings.value_changed', (payload) => {
|
|
37
|
+
const settingCode = payload.settingCode;
|
|
38
|
+
if (typeof settingCode !== 'string' || !settingCode.startsWith('google_tag_manager.'))
|
|
39
|
+
return;
|
|
40
|
+
ctx.cradle().googleTagManagerServices.invalidateConfig();
|
|
41
|
+
});
|
|
42
|
+
ctx.routes(async (app) => {
|
|
43
|
+
const { googleTagManagerServices, settingsReadPort, moduleQueueRedis } = ctx.cradle();
|
|
44
|
+
googleTagManagerServices.revalidator.setLogger(app.log);
|
|
45
|
+
// Queue consumer (Principle X): a separable worker entrypoint, gated on
|
|
46
|
+
// role. `ctx.worker` wraps it in `defineModuleWorker`.
|
|
47
|
+
if (process.env['BACKEND_ROLE'] !== 'api' && moduleQueueRedis) {
|
|
48
|
+
ctx.worker(createGtmRelayWorker(moduleQueueRedis, makeProcessor({ settings: settingsReadPort, client: new SgtmClient() })), { logger: app.log });
|
|
49
|
+
}
|
|
50
|
+
await registerGoogleTagManagerStorefrontRoutes(app, {
|
|
51
|
+
configService: googleTagManagerServices.configService,
|
|
52
|
+
...(googleTagManagerServices.enqueueRelay
|
|
53
|
+
? { enqueueRelay: googleTagManagerServices.enqueueRelay }
|
|
54
|
+
: {}),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The module owns no persisted entity class, so the array is empty, on the `./backend` subpath, as one
|
|
60
|
+
* array and **no named class export** (D-168).
|
|
61
|
+
*
|
|
62
|
+
* This is the shape the platform reads when the package is *installed*: the
|
|
63
|
+
* boot-time loader (`src/packages/package-runtime.ts`, `exported['entities']`)
|
|
64
|
+
* and the static declaration reader (`scripts/lib/package-declarations.ts`),
|
|
65
|
+
* which is the third source of `check:module-boundary`'s `table→owner` map and
|
|
66
|
+
* the package pass of `check-entity-tenant-classification`. A missing array is
|
|
67
|
+
* answered with `[]` — zero entities registered, no error anywhere.
|
|
68
|
+
*/
|
|
69
|
+
export const entities = [];
|
|
70
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/backend/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { GtmConfigService } from './services/gtm-config.service.js';
|
|
3
|
+
import type { GtmCollectEnqueuer } from './services/ss-relay-queue.js';
|
|
4
|
+
export interface GoogleTagManagerStorefrontDeps {
|
|
5
|
+
configService: GtmConfigService;
|
|
6
|
+
/** Producer that enqueues server-side relay jobs (US3). Absent ⇒ 503. */
|
|
7
|
+
enqueueRelay?: GtmCollectEnqueuer;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Public storefront routes for the Google Tag Manager module (feature 066).
|
|
11
|
+
* Wrapped by `defineModuleRoutes` in plugin.ts so they 503 when the module is
|
|
12
|
+
* disabled. The sales channel comes from the shared resolver middleware
|
|
13
|
+
* (Principle XII) — never from the request body.
|
|
14
|
+
*/
|
|
15
|
+
export declare function registerGoogleTagManagerStorefrontRoutes(app: FastifyInstance, deps: GoogleTagManagerStorefrontDeps): Promise<void>;
|
|
16
|
+
//# sourceMappingURL=routes.storefront.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.storefront.d.ts","sourceRoot":"","sources":["../../src/backend/routes.storefront.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AAEvE,MAAM,WAAW,8BAA8B;IAC7C,aAAa,EAAE,gBAAgB,CAAC;IAChC,yEAAyE;IACzE,YAAY,CAAC,EAAE,kBAAkB,CAAC;CACnC;AAED;;;;;GAKG;AACH,wBAAsB,wCAAwC,CAC5D,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,8BAA8B,GACnC,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { gtmCollectRequestSchema } from '@endora-commerce/contracts';
|
|
2
|
+
import { getResolvedChannel } from '@endora-commerce/platform/kernel';
|
|
3
|
+
/**
|
|
4
|
+
* Public storefront routes for the Google Tag Manager module (feature 066).
|
|
5
|
+
* Wrapped by `defineModuleRoutes` in plugin.ts so they 503 when the module is
|
|
6
|
+
* disabled. The sales channel comes from the shared resolver middleware
|
|
7
|
+
* (Principle XII) — never from the request body.
|
|
8
|
+
*/
|
|
9
|
+
export async function registerGoogleTagManagerStorefrontRoutes(app, deps) {
|
|
10
|
+
app.get('/api/v1/storefront/google-tag-manager/config', async (request, reply) => {
|
|
11
|
+
const channel = getResolvedChannel(request);
|
|
12
|
+
const config = await deps.configService.getConfig(channel.id);
|
|
13
|
+
return reply.send({ data: config });
|
|
14
|
+
});
|
|
15
|
+
// Server-side relay ingest — producer only (Principle X): validate → enqueue
|
|
16
|
+
// → 202. Nothing is forwarded inside the shopper's request (FR-029).
|
|
17
|
+
app.post('/api/v1/storefront/google-tag-manager/collect', async (request, reply) => {
|
|
18
|
+
if (!deps.enqueueRelay) {
|
|
19
|
+
return reply.status(503).send({
|
|
20
|
+
error: {
|
|
21
|
+
code: 'server_side_unavailable',
|
|
22
|
+
message: 'Server-side tagging is not available.',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const channel = getResolvedChannel(request);
|
|
27
|
+
// The schema is the enforcement point for the closed event allow-list: an
|
|
28
|
+
// event name outside it is a 400 and is never enqueued (FR-024 / FR-025).
|
|
29
|
+
const body = gtmCollectRequestSchema.parse(request.body);
|
|
30
|
+
// Read from the connection and the headers, never from the body — a client
|
|
31
|
+
// must not be able to nominate someone else's IP or user agent.
|
|
32
|
+
const userAgent = request.headers['user-agent'];
|
|
33
|
+
const accepted = await deps.enqueueRelay(channel.id, body, {
|
|
34
|
+
...(request.ip ? { ip: request.ip } : {}),
|
|
35
|
+
...(userAgent ? { userAgent } : {}),
|
|
36
|
+
});
|
|
37
|
+
return reply.status(202).send({ data: { accepted } });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=routes.storefront.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.storefront.js","sourceRoot":"","sources":["../../src/backend/routes.storefront.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAUtE;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,wCAAwC,CAC5D,GAAoB,EACpB,IAAoC;IAEpC,GAAG,CAAC,GAAG,CAAC,8CAA8C,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE;QAC/E,MAAM,OAAO,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;QAC5C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC9D,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,qEAAqE;IACrE,GAAG,CAAC,IAAI,CAAC,+CAA+C,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE;QACjF,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACvB,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBAC5B,KAAK,EAAE;oBACL,IAAI,EAAE,yBAAyB;oBAC/B,OAAO,EAAE,uCAAuC;iBACjD;aACF,CAAC,CAAC;QACL,CAAC;QACD,MAAM,OAAO,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;QAC5C,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAM,IAAI,GAAG,uBAAuB,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACzD,2EAA2E;QAC3E,gEAAgE;QAChE,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;QAChD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE;YACzD,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACzC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpC,CAAC,CAAC;QACH,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type GtmStorefrontConfig } from '@endora-commerce/contracts';
|
|
2
|
+
import type { SettingsReadPort } from '@endora-commerce/platform/kernel';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the per-sales-channel Google Tag Manager configuration exposed to
|
|
5
|
+
* the storefront.
|
|
6
|
+
*
|
|
7
|
+
* A blank or malformed container ID means the channel is untracked (FR-004 /
|
|
8
|
+
* FR-005): the returned config is fully disabled regardless of the master
|
|
9
|
+
* switch, so a typo can never reach a `<script src>`. `requireConsent` survives
|
|
10
|
+
* every disabled branch, because the storefront's shared consent gate reasons
|
|
11
|
+
* about the channel even when this module contributes nothing.
|
|
12
|
+
*
|
|
13
|
+
* Every setting read degrades to its documented default rather than throwing —
|
|
14
|
+
* an incomplete settings catalogue must not be able to fail a storefront
|
|
15
|
+
* render.
|
|
16
|
+
*
|
|
17
|
+
* The server container address is deliberately absent from the result: the
|
|
18
|
+
* browser only needs to know *that* it should relay, never *where* to.
|
|
19
|
+
*/
|
|
20
|
+
export declare class GtmConfigService {
|
|
21
|
+
private readonly settings;
|
|
22
|
+
constructor(settings: SettingsReadPort);
|
|
23
|
+
getConfig(salesChannelId: string): Promise<GtmStorefrontConfig>;
|
|
24
|
+
private readBool;
|
|
25
|
+
private readString;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=gtm-config.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gtm-config.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/gtm-config.service.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAEzE;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,EAAE,gBAAgB;IAEjD,SAAS,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;YAmCvD,QAAQ;YAYR,UAAU;CAWzB"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { GOOGLE_TAG_MANAGER_SETTING_CODES, GTM_DISABLED_CONFIG, gtmContainerIdSchema, } from '@endora-commerce/contracts';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the per-sales-channel Google Tag Manager configuration exposed to
|
|
5
|
+
* the storefront.
|
|
6
|
+
*
|
|
7
|
+
* A blank or malformed container ID means the channel is untracked (FR-004 /
|
|
8
|
+
* FR-005): the returned config is fully disabled regardless of the master
|
|
9
|
+
* switch, so a typo can never reach a `<script src>`. `requireConsent` survives
|
|
10
|
+
* every disabled branch, because the storefront's shared consent gate reasons
|
|
11
|
+
* about the channel even when this module contributes nothing.
|
|
12
|
+
*
|
|
13
|
+
* Every setting read degrades to its documented default rather than throwing —
|
|
14
|
+
* an incomplete settings catalogue must not be able to fail a storefront
|
|
15
|
+
* render.
|
|
16
|
+
*
|
|
17
|
+
* The server container address is deliberately absent from the result: the
|
|
18
|
+
* browser only needs to know *that* it should relay, never *where* to.
|
|
19
|
+
*/
|
|
20
|
+
export class GtmConfigService {
|
|
21
|
+
settings;
|
|
22
|
+
constructor(settings) {
|
|
23
|
+
this.settings = settings;
|
|
24
|
+
}
|
|
25
|
+
async getConfig(salesChannelId) {
|
|
26
|
+
const [enabled, containerRaw, requireConsent] = await Promise.all([
|
|
27
|
+
this.readBool(GOOGLE_TAG_MANAGER_SETTING_CODES.ENABLED, salesChannelId, false),
|
|
28
|
+
this.readString(GOOGLE_TAG_MANAGER_SETTING_CODES.CONTAINER_ID, salesChannelId, ''),
|
|
29
|
+
this.readBool(GOOGLE_TAG_MANAGER_SETTING_CODES.REQUIRE_CONSENT, salesChannelId, true),
|
|
30
|
+
]);
|
|
31
|
+
const containerId = gtmContainerIdSchema.safeParse(containerRaw.trim());
|
|
32
|
+
if (!enabled || !containerId.success) {
|
|
33
|
+
return { ...GTM_DISABLED_CONFIG, requireConsent };
|
|
34
|
+
}
|
|
35
|
+
const [serverSideEnabled, serverContainerUrl] = await Promise.all([
|
|
36
|
+
this.readBool(GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_SIDE_ENABLED, salesChannelId, false),
|
|
37
|
+
this.readString(GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_CONTAINER_URL, salesChannelId, ''),
|
|
38
|
+
]);
|
|
39
|
+
return {
|
|
40
|
+
enabled: true,
|
|
41
|
+
containerId: containerId.data,
|
|
42
|
+
requireConsent,
|
|
43
|
+
// Server-side tagging with no destination is inert: the channel stays on
|
|
44
|
+
// the browser path rather than losing events (FR-031).
|
|
45
|
+
serverSide: serverSideEnabled && serverContainerUrl.trim() !== '',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async readBool(code, salesChannelId, fallback) {
|
|
49
|
+
try {
|
|
50
|
+
return await this.settings.get(code, salesChannelId, z.boolean());
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async readString(code, salesChannelId, fallback) {
|
|
57
|
+
try {
|
|
58
|
+
return await this.settings.get(code, salesChannelId, z.string());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=gtm-config.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gtm-config.service.js","sourceRoot":"","sources":["../../../src/backend/services/gtm-config.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,gCAAgC,EAChC,mBAAmB,EACnB,oBAAoB,GAErB,MAAM,4BAA4B,CAAC;AAGpC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,OAAO,gBAAgB;IACE;IAA7B,YAA6B,QAA0B;QAA1B,aAAQ,GAAR,QAAQ,CAAkB;IAAG,CAAC;IAE3D,KAAK,CAAC,SAAS,CAAC,cAAsB;QACpC,MAAM,CAAC,OAAO,EAAE,YAAY,EAAE,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAChE,IAAI,CAAC,QAAQ,CAAC,gCAAgC,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,CAAC;YAC9E,IAAI,CAAC,UAAU,CAAC,gCAAgC,CAAC,YAAY,EAAE,cAAc,EAAE,EAAE,CAAC;YAClF,IAAI,CAAC,QAAQ,CAAC,gCAAgC,CAAC,eAAe,EAAE,cAAc,EAAE,IAAI,CAAC;SACtF,CAAC,CAAC;QAEH,MAAM,WAAW,GAAG,oBAAoB,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI,EAAE,CAAC,CAAC;QACxE,IAAI,CAAC,OAAO,IAAI,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC;YACrC,OAAO,EAAE,GAAG,mBAAmB,EAAE,cAAc,EAAE,CAAC;QACpD,CAAC;QAED,MAAM,CAAC,iBAAiB,EAAE,kBAAkB,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAChE,IAAI,CAAC,QAAQ,CACX,gCAAgC,CAAC,mBAAmB,EACpD,cAAc,EACd,KAAK,CACN;YACD,IAAI,CAAC,UAAU,CACb,gCAAgC,CAAC,oBAAoB,EACrD,cAAc,EACd,EAAE,CACH;SACF,CAAC,CAAC;QAEH,OAAO;YACL,OAAO,EAAE,IAAI;YACb,WAAW,EAAE,WAAW,CAAC,IAAI;YAC7B,cAAc;YACd,yEAAyE;YACzE,uDAAuD;YACvD,UAAU,EAAE,iBAAiB,IAAI,kBAAkB,CAAC,IAAI,EAAE,KAAK,EAAE;SAClE,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,QAAQ,CACpB,IAAY,EACZ,cAAsB,EACtB,QAAiB;QAEjB,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;QACpE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,QAAQ,CAAC;QAClB,CAAC;IACH,CAAC;IAEO,KAAK,CAAC,UAAU,CACtB,IAAY,EACZ,cAAsB,EACtB,QAAgB;QAEhB,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;QACnE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,QAAQ,CAAC;QAClB,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the operator's server-side Google Tag Manager container (feature
|
|
3
|
+
* 066, US3).
|
|
4
|
+
*
|
|
5
|
+
* Posts one event to `{server_container_url}{server_ingest_path}` as a JSON
|
|
6
|
+
* envelope the container's ingest client parses — Google's Data Client (default
|
|
7
|
+
* path `/data`) or an equivalent custom client. No credentials are sent: an
|
|
8
|
+
* sGTM ingest endpoint is a public collection endpoint by design, exactly like
|
|
9
|
+
* `google-analytics.com/g/collect`.
|
|
10
|
+
*
|
|
11
|
+
* Like `Ga4MpClient` and unlike the legacy fire-and-forget forwarder, this
|
|
12
|
+
* client THROWS on a non-2xx response or a transport error so the BullMQ worker
|
|
13
|
+
* retries (Principle X / FR-027). `fetchFn` is injectable, so no test touches
|
|
14
|
+
* the network.
|
|
15
|
+
*/
|
|
16
|
+
import type { GtmPageContext } from '@endora-commerce/contracts';
|
|
17
|
+
export interface SgtmDestination {
|
|
18
|
+
/** Base URL of the server container, e.g. `https://sgtm.example.com`. */
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
/** Request path the container's ingest client claims. Blank ⇒ `/data`. */
|
|
21
|
+
ingestPath: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SgtmEvent {
|
|
24
|
+
eventId: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
name: string;
|
|
27
|
+
params: Record<string, string | number | boolean>;
|
|
28
|
+
consent: {
|
|
29
|
+
analyticsStorage: 'granted' | 'denied';
|
|
30
|
+
};
|
|
31
|
+
page: GtmPageContext;
|
|
32
|
+
/** Present only when the relay processor kept them (granted consent). */
|
|
33
|
+
ip?: string;
|
|
34
|
+
userAgent?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface SgtmClientOptions {
|
|
37
|
+
fetchFn?: typeof fetch;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
export declare class SgtmClient {
|
|
41
|
+
private readonly options;
|
|
42
|
+
constructor(options?: SgtmClientOptions);
|
|
43
|
+
get timeoutMs(): number;
|
|
44
|
+
send(dest: SgtmDestination, event: SgtmEvent): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=sgtm-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sgtm-client.d.ts","sourceRoot":"","sources":["../../../src/backend/services/sgtm-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;IAClD,OAAO,EAAE;QAAE,gBAAgB,EAAE,SAAS,GAAG,QAAQ,CAAA;KAAE,CAAC;IACpD,IAAI,EAAE,cAAc,CAAC;IACrB,yEAAyE;IACzE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA4BD,qBAAa,UAAU;IACT,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,GAAE,iBAAsB;IAE5D,IAAI,SAAS,IAAI,MAAM,CAEtB;IAEK,IAAI,CAAC,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;CA4CnE"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the operator's server-side Google Tag Manager container (feature
|
|
3
|
+
* 066, US3).
|
|
4
|
+
*
|
|
5
|
+
* Posts one event to `{server_container_url}{server_ingest_path}` as a JSON
|
|
6
|
+
* envelope the container's ingest client parses — Google's Data Client (default
|
|
7
|
+
* path `/data`) or an equivalent custom client. No credentials are sent: an
|
|
8
|
+
* sGTM ingest endpoint is a public collection endpoint by design, exactly like
|
|
9
|
+
* `google-analytics.com/g/collect`.
|
|
10
|
+
*
|
|
11
|
+
* Like `Ga4MpClient` and unlike the legacy fire-and-forget forwarder, this
|
|
12
|
+
* client THROWS on a non-2xx response or a transport error so the BullMQ worker
|
|
13
|
+
* retries (Principle X / FR-027). `fetchFn` is injectable, so no test touches
|
|
14
|
+
* the network.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_INGEST_PATH = '/data';
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
18
|
+
export class SgtmClient {
|
|
19
|
+
options;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
}
|
|
23
|
+
get timeoutMs() {
|
|
24
|
+
return this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
25
|
+
}
|
|
26
|
+
async send(dest, event) {
|
|
27
|
+
const fetchFn = this.options.fetchFn ?? globalThis.fetch;
|
|
28
|
+
const base = dest.baseUrl.trim().replace(/\/+$/, '');
|
|
29
|
+
const rawPath = dest.ingestPath.trim() || DEFAULT_INGEST_PATH;
|
|
30
|
+
const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
31
|
+
const granted = event.consent.analyticsStorage === 'granted';
|
|
32
|
+
const envelope = {
|
|
33
|
+
event_name: event.name,
|
|
34
|
+
client_id: event.clientId,
|
|
35
|
+
event_id: event.eventId,
|
|
36
|
+
page_location: event.page.location,
|
|
37
|
+
...(event.page.referrer ? { page_referrer: event.page.referrer } : {}),
|
|
38
|
+
...(event.page.title ? { page_title: event.page.title } : {}),
|
|
39
|
+
...(event.page.language ? { language: event.page.language } : {}),
|
|
40
|
+
consent: { analytics_storage: event.consent.analyticsStorage },
|
|
41
|
+
// Second enforcement point for FR-030: the processor already strips them
|
|
42
|
+
// on denied consent, and the last gate before the wire refuses to send
|
|
43
|
+
// them regardless of what reached it.
|
|
44
|
+
...(granted && event.ip ? { ip_override: event.ip } : {}),
|
|
45
|
+
...(granted && event.userAgent ? { user_agent: event.userAgent } : {}),
|
|
46
|
+
};
|
|
47
|
+
// Params are flattened at the top level, but the envelope wins on a
|
|
48
|
+
// collision — a caller-supplied `event_name` must not be able to redirect
|
|
49
|
+
// the operator's tags.
|
|
50
|
+
const body = { ...event.params, ...envelope };
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetchFn(`${base}${path}`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`sGTM delivery failed with status ${res.status}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=sgtm-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sgtm-client.js","sourceRoot":"","sources":["../../../src/backend/services/sgtm-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Queue, Worker, type Processor, type QueueOptions, type WorkerOptions } from 'bullmq';
|
|
2
|
+
import type { Redis } from 'ioredis';
|
|
3
|
+
import type { GtmCollectRequest, GtmPageContext } from '@endora-commerce/contracts';
|
|
4
|
+
/**
|
|
5
|
+
* BullMQ queue for the server-side Google Tag Manager relay (feature 066, US3 /
|
|
6
|
+
* Principle X). The storefront `/collect` route is a pure producer: it
|
|
7
|
+
* validates and enqueues one job per event, and the worker forwards each to the
|
|
8
|
+
* operator's server container, throwing on failure so BullMQ retries.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here is persisted in PostgreSQL — the relay is stateless, and
|
|
11
|
+
* `removeOnFail` retention is what makes an exhausted delivery observable
|
|
12
|
+
* (FR-027).
|
|
13
|
+
*/
|
|
14
|
+
export interface GtmRelayJobData {
|
|
15
|
+
/** Also the BullMQ `jobId` — the idempotency key across retries (FR-028). */
|
|
16
|
+
eventId: string;
|
|
17
|
+
salesChannelId: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
event: {
|
|
20
|
+
name: string;
|
|
21
|
+
params: Record<string, string | number | boolean>;
|
|
22
|
+
};
|
|
23
|
+
consent: {
|
|
24
|
+
analyticsStorage: 'granted' | 'denied';
|
|
25
|
+
};
|
|
26
|
+
page: GtmPageContext;
|
|
27
|
+
/** Captured server-side at ingest; forwarded only with granted consent (FR-030). */
|
|
28
|
+
ip?: string;
|
|
29
|
+
userAgent?: string;
|
|
30
|
+
/** Stamped by the producer, so retry latency does not distort the timing. */
|
|
31
|
+
occurredAt: string;
|
|
32
|
+
}
|
|
33
|
+
/** Server-observed request context the browser is never trusted to supply. */
|
|
34
|
+
export interface GtmIngestContext {
|
|
35
|
+
ip?: string;
|
|
36
|
+
userAgent?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Producer callback exposed to the storefront route. Assigns an `eventId` per
|
|
40
|
+
* event, enqueues a relay job for each, and returns how many were accepted.
|
|
41
|
+
*/
|
|
42
|
+
export type GtmCollectEnqueuer = (salesChannelId: string, request: GtmCollectRequest, context: GtmIngestContext) => Promise<number>;
|
|
43
|
+
export declare const GTM_RELAY_QUEUE_NAME = "google_tag_manager.ss.relay";
|
|
44
|
+
export declare function createGtmRelayQueue(redis: Redis, overrides?: Partial<QueueOptions>): Queue<GtmRelayJobData>;
|
|
45
|
+
export declare function createGtmRelayWorker(redis: Redis, processor: Processor<GtmRelayJobData>, overrides?: Partial<WorkerOptions>): Worker<GtmRelayJobData>;
|
|
46
|
+
//# sourceMappingURL=ss-relay-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ss-relay-queue.d.ts","sourceRoot":"","sources":["../../../src/backend/services/ss-relay-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE9F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEpF;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,CAAC;IAC3E,OAAO,EAAE;QAAE,gBAAgB,EAAE,SAAS,GAAG,QAAQ,CAAA;KAAE,CAAC;IACpD,IAAI,EAAE,cAAc,CAAC;IACrB,oFAAoF;IACpF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAC/B,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,gBAAgB,KACtB,OAAO,CAAC,MAAM,CAAC,CAAC;AAErB,eAAO,MAAM,oBAAoB,gCAAgC,CAAC;AAKlE,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,KAAK,EACZ,SAAS,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,GAChC,KAAK,CAAC,eAAe,CAAC,CAYxB;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,SAAS,CAAC,eAAe,CAAC,EACrC,SAAS,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,GACjC,MAAM,CAAC,eAAe,CAAC,CAazB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Queue, Worker } from 'bullmq';
|
|
2
|
+
import { enterSystemScope } from '@endora-commerce/platform/kernel';
|
|
3
|
+
export const GTM_RELAY_QUEUE_NAME = 'google_tag_manager.ss.relay';
|
|
4
|
+
const DEFAULT_ATTEMPTS = 8;
|
|
5
|
+
const DEFAULT_BACKOFF = { type: 'exponential', delay: 1_000 };
|
|
6
|
+
export function createGtmRelayQueue(redis, overrides) {
|
|
7
|
+
const options = {
|
|
8
|
+
connection: redis,
|
|
9
|
+
defaultJobOptions: {
|
|
10
|
+
attempts: DEFAULT_ATTEMPTS,
|
|
11
|
+
backoff: DEFAULT_BACKOFF,
|
|
12
|
+
removeOnComplete: { count: 500 },
|
|
13
|
+
removeOnFail: { count: 5_000 },
|
|
14
|
+
},
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
return new Queue(GTM_RELAY_QUEUE_NAME, options);
|
|
18
|
+
}
|
|
19
|
+
export function createGtmRelayWorker(redis, processor, overrides) {
|
|
20
|
+
const options = {
|
|
21
|
+
connection: redis,
|
|
22
|
+
concurrency: 8,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
// Feature 072 (T033) — the job establishes its own scope; it established
|
|
26
|
+
// nothing before.
|
|
27
|
+
return new Worker(GTM_RELAY_QUEUE_NAME, (job) => enterSystemScope('google_tag_manager: server-side relay', () => processor(job)), options);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=ss-relay-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ss-relay-queue.js","sourceRoot":"","sources":["../../../src/backend/services/ss-relay-queue.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAyD,MAAM,QAAQ,CAAC;AAC9F,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AA6CpE,MAAM,CAAC,MAAM,oBAAoB,GAAG,6BAA6B,CAAC;AAElE,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAC3B,MAAM,eAAe,GAAG,EAAE,IAAI,EAAE,aAAsB,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAEvE,MAAM,UAAU,mBAAmB,CACjC,KAAY,EACZ,SAAiC;IAEjC,MAAM,OAAO,GAAiB;QAC5B,UAAU,EAAE,KAAK;QACjB,iBAAiB,EAAE;YACjB,QAAQ,EAAE,gBAAgB;YAC1B,OAAO,EAAE,eAAe;YACxB,gBAAgB,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE;YAChC,YAAY,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;SAC/B;QACD,GAAG,SAAS;KACb,CAAC;IACF,OAAO,IAAI,KAAK,CAAkB,oBAAoB,EAAE,OAAO,CAAC,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,KAAY,EACZ,SAAqC,EACrC,SAAkC;IAElC,MAAM,OAAO,GAAkB;QAC7B,UAAU,EAAE,KAAK;QACjB,WAAW,EAAE,CAAC;QACd,GAAG,SAAS;KACb,CAAC;IACF,yEAAyE;IACzE,kBAAkB;IAClB,OAAO,IAAI,MAAM,CACf,oBAAoB,EACpB,CAAC,GAAG,EAAE,EAAE,CAAC,gBAAgB,CAAC,uCAAuC,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,EACxF,OAAO,CACR,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Processor, Queue } from 'bullmq';
|
|
2
|
+
import { type GtmCollectRequest } from '@endora-commerce/contracts';
|
|
3
|
+
import type { SettingsReadPort } from '@endora-commerce/platform/kernel';
|
|
4
|
+
import type { GtmIngestContext, GtmRelayJobData } from './ss-relay-queue.js';
|
|
5
|
+
import type { SgtmClient } from './sgtm-client.js';
|
|
6
|
+
/**
|
|
7
|
+
* Server-side relay orchestration (feature 066, US3).
|
|
8
|
+
*
|
|
9
|
+
* - `makeEnqueuer` returns the producer used by the `/collect` route: it
|
|
10
|
+
* assigns one uuid `eventId` per event (the idempotency key and the BullMQ
|
|
11
|
+
* `jobId`) and enqueues one relay job each. Pure producer — no outbound call
|
|
12
|
+
* happens in the shopper's request (Principle X / FR-029).
|
|
13
|
+
* - `makeProcessor` returns the BullMQ processor: it resolves the channel's
|
|
14
|
+
* server container address from Settings, drops the visitor's IP and user
|
|
15
|
+
* agent unless consent was granted (FR-030), and forwards through the sGTM
|
|
16
|
+
* client, letting a failure throw so BullMQ retries.
|
|
17
|
+
*/
|
|
18
|
+
export declare function makeEnqueuer(queue: Queue<GtmRelayJobData>): (salesChannelId: string, request: GtmCollectRequest, context: GtmIngestContext) => Promise<number>;
|
|
19
|
+
export interface GtmRelayProcessorDeps {
|
|
20
|
+
settings: SettingsReadPort;
|
|
21
|
+
client: SgtmClient;
|
|
22
|
+
}
|
|
23
|
+
export declare function makeProcessor(deps: GtmRelayProcessorDeps): Processor<GtmRelayJobData>;
|
|
24
|
+
//# sourceMappingURL=ss-relay.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ss-relay.service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/ss-relay.service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAC/C,OAAO,EAAoC,KAAK,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AACtG,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAE9D;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,CAAC,eAAe,CAAC,IAEtD,gBAAgB,MAAM,EACtB,SAAS,iBAAiB,EAC1B,SAAS,gBAAgB,KACxB,OAAO,CAAC,MAAM,CAAC,CA4BnB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,qBAAqB,GAAG,SAAS,CAAC,eAAe,CAAC,CAmCrF"}
|