@mindful-web/marko-web-omeda-identity-x 1.90.0 → 1.90.2

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/README.md CHANGED
@@ -99,6 +99,85 @@ A related trap when reading site configs: some repos' `onAuthenticationSuccess.p
99
99
  `product.deploymentTypeId` and emits `deploymentTypes`. Check which formatter consumes a list before
100
100
  assuming its ids are products.
101
101
 
102
+ ### Per-source overrides (`source`)
103
+
104
+ A hook's appends are otherwise unconditional — every login source gets the same products, opt-ins
105
+ and demographics. Add a `source` map to vary them by where the conversion came from:
106
+
107
+ ```js
108
+ // sites/<site>/config/identity-x-opt-in-hooks.js
109
+ module.exports = {
110
+ onLoginLinkSent: {
111
+ productIds: [15375],
112
+ deploymentTypeIds: [332],
113
+ source: {
114
+ contentGating: { productIds: [] }, // no product for either content gate…
115
+ contentGate: { productIds: [42] }, // …except this one specifically
116
+ },
117
+ },
118
+ };
119
+ ```
120
+
121
+ Resolve it in a formatter with
122
+ [`resolveOptInHookConfig`](./utils/resolve-opt-in-hook-config.js), which returns `undefined` when
123
+ the hook isn't configured, so the usual early bail is unchanged:
124
+
125
+ ```js
126
+ const resolveOptInHookConfig = require('@mindful-web/marko-web-omeda-identity-x/utils/resolve-opt-in-hook-config');
127
+
128
+ const { demographics, deploymentTypeIds, productIds } = resolveOptInHookConfig({
129
+ optInHooks: identityXOptInHooks,
130
+ hook: 'onLoginLinkSent',
131
+ source: loginSource,
132
+ }) || {};
133
+ ```
134
+
135
+ **Layering** is `base → bucket → exact source`, each a shallow **per-key replace**: only the keys
136
+ an override names are replaced, the rest inherit. That is what lets `productIds: []` mean "no
137
+ products for this source" while leaving `deploymentTypeIds` alone. An empty array is truthy, so the
138
+ existing `(productIds || []).reduce(...)` in every formatter produces no appends unchanged. An
139
+ exact-source key always beats the bucket containing it.
140
+
141
+ **Keys** may be a bucket or an exact login source, in any casing (`contentGate`,
142
+ `content_meter_login` and `CONTENT_GATE` are equivalent). The buckets are the four areas of the
143
+ Insights → Audience → Audience Conversion report:
144
+
145
+ | Bucket key | Login sources |
146
+ |---|---|
147
+ | `contentGating` | `contentGate`, `content_meter_login` |
148
+ | `newsletter` (or `newsletterSignups`) | `newsletterSignup`, `recommendedSignup` |
149
+ | `featureGating` | `comments`, `contentAccess`, `contentDownload`, `equipmentCalculator`, `loadAnalyzer`, `top250`, `pib_login`, `contactCompany`, `ingredientIssues` |
150
+ | `other` | `default`, `subscribe`, `google-one-tap`, `idxApi` |
151
+
152
+ Two groupings surprise people, and both are deliberate upstream: **`comments` is feature gating**,
153
+ not other; and **`contentAccess` / `contentDownload` are feature gating**, not content gating (they
154
+ gate an asset rather than an article, and have no Views signal). Sources in no bucket —
155
+ `change-email`, `progressiveProfile`, `truckHistoryReport`, `poultryTrendsLiveChartPage` — are
156
+ reachable only by an exact-source key.
157
+
158
+ The bucket definitions are mirrored from `mindful-reporting`'s
159
+ `packages/audience/repo/src/repos/member-events/conversion-sources.ts`; there is no package shared
160
+ between the two repos, so [`conversion-source-buckets.js`](./utils/conversion-source-buckets.js)
161
+ duplicates them. **Edit both together** — a source missing here silently falls back to the base
162
+ config rather than erroring.
163
+
164
+ Two caveats:
165
+
166
+ - **Each hook names the value differently.** `onLoginLinkSent` receives `source`,
167
+ `onAuthenticationSuccess` receives `loginSource`, `onUserProfileUpdate` receives `actionSource`.
168
+ Pass whichever local holds it.
169
+ - **`onUserProfileUpdate` reports which surface saved the profile, not a registration source.**
170
+ Both routes that fire it now pass one: `routes/profile.js` sends `profileUpdate` and
171
+ `routes/progressive-profile.js` sends `progressiveProfile` (a client-supplied
172
+ `additionalEventData.actionSource` still wins over either). Neither is in any conversion bucket,
173
+ so **only an exact-source key matches them** — a `newsletter` or `contentGating` bucket override
174
+ will never apply to this hook. Before mindful-web#341 no route passed a source at all and every
175
+ override here was inert; if you are reading an older tenant config that relied on that, it now
176
+ resolves.
177
+
178
+ An unrecognized, empty or missing source is never an error: the set of login sources is open (any
179
+ site template can introduce a new `source="…"`), so it resolves to the base config.
180
+
102
181
  ### Customer re-sync interval
103
182
 
104
183
  `userResyncIntervalMs` is read from the **IdentityX** config (`idxConfig`), not from the properties
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.90.0/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.90.2/components/identify.marko",
6
6
  marko_component = require("./identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_getCookieId = require("@mindful-web/marko-web-omeda-identity-x/utils/get-cookie-id"),
@@ -51,7 +51,7 @@ marko_template._ = marko_renderer(render, {
51
51
  }, marko_component);
52
52
 
53
53
  marko_template.meta = {
54
- id: "/@mindful-web/marko-web-omeda-identity-x$1.90.0/components/identify.marko",
54
+ id: "/@mindful-web/marko-web-omeda-identity-x$1.90.2/components/identify.marko",
55
55
  component: "./identify.marko",
56
56
  tags: [
57
57
  "@mindful-web/marko-web-identity-x/components/identify.marko",
package/index.js CHANGED
@@ -296,11 +296,24 @@ module.exports = (app, params = {}) => {
296
296
  * "onUserProfileUpdate"
297
297
  * )} IDXHookTypeEnum
298
298
  *
299
+ * The login sources shipped by this monorepo's packages. **Not exhaustive** — the set is open:
300
+ * a site template may pass any `source="…"` string of its own (fusable alone adds `loadAnalyzer`,
301
+ * `top250`, `pib_login`, `equipmentCalculator`), so treat an unlisted value as valid data rather
302
+ * than a bug. Grouping into report buckets lives in `utils/conversion-source-buckets.js`.
303
+ *
299
304
  * @typedef {(
300
305
  * "default"|
301
306
  * "newsletterSignup"|
307
+ * "recommendedSignup"|
302
308
  * "comments"|
303
- * "contentGate"
309
+ * "contentGate"|
310
+ * "content_meter_login"|
311
+ * "contentAccess"|
312
+ * "contentDownload"|
313
+ * "progressiveProfile"|
314
+ * "google-one-tap"|
315
+ * "change-email"|
316
+ * "subscribe"
304
317
  * )} OIDXLoginSource
305
318
  *
306
319
  * @typedef {(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-omeda-identity-x",
3
- "version": "1.90.0",
3
+ "version": "1.90.2",
4
4
  "description": "Marko Omeda+IdentityX integration tools",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-omeda-identity-x",
6
6
  "author": "Josh Worden <josh@parameter1.com>",
@@ -13,7 +13,7 @@
13
13
  "test": "yarn compile --no-clean && yarn lint && mocha --reporter spec"
14
14
  },
15
15
  "dependencies": {
16
- "@mindful-web/marko-web-identity-x": "^1.89.2",
16
+ "@mindful-web/marko-web-identity-x": "^1.90.2",
17
17
  "@mindful-web/marko-web-omeda": "^1.88.0",
18
18
  "@mindful-web/object-path": "^1.83.1",
19
19
  "@mindful-web/utils": "^1.83.1",
@@ -33,5 +33,5 @@
33
33
  "chai": "^4.3.7",
34
34
  "mocha": "^6.2.3"
35
35
  },
36
- "gitHead": "a211cd726396ecc45da80a4571d40d01d5b3f555"
36
+ "gitHead": "0e498d1c31b3c6a8856b9d75cd9233a121fa173b"
37
37
  }
@@ -0,0 +1,65 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const buildBehaviorFactory = require('../utils/build-behavior');
4
+
5
+ // A realistic tenant behavior config (PMMI's, trimmed). Note what `actionSource` does NOT map:
6
+ // there is no `valueId` fallback on it and no entry for the profile-update sources, which is the
7
+ // property the tests below pin.
8
+ const behaviors = { logIn: 103, verifyEmail: 104, submitProfile: 105 };
9
+ const behaviorAttributes = {
10
+ website: { id: 8, valueId: 12345 },
11
+ actionSource: {
12
+ id: 11,
13
+ valueIds: {
14
+ default: 199623,
15
+ newsletterSignup: 199625,
16
+ comments: 199626,
17
+ contentGate: 199620,
18
+ },
19
+ },
20
+ };
21
+
22
+ const buildBehaviorFor = buildBehaviorFactory({ behaviors, behaviorAttributes });
23
+ const attrsById = (behavior) => behavior.attributes.reduce((o, { id, valueId }) => ({
24
+ ...o,
25
+ [id]: valueId,
26
+ }), {});
27
+
28
+ describe('utils/build-behavior', () => {
29
+ it('maps each hook to its behavior id', () => {
30
+ expect(buildBehaviorFor('onLoginLinkSent', {}).id).to.equal(103);
31
+ expect(buildBehaviorFor('onAuthenticationSuccess', {}).id).to.equal(104);
32
+ expect(buildBehaviorFor('onUserProfileUpdate', {}).id).to.equal(105);
33
+ expect(buildBehaviorFor('onSomethingElse', {})).to.equal(undefined);
34
+ });
35
+
36
+ it('resolves a mapped action source to its value id', () => {
37
+ const attrs = attrsById(buildBehaviorFor('onLoginLinkSent', { actionSource: 'contentGate' }));
38
+ expect(attrs[11]).to.equal(199620);
39
+ });
40
+
41
+ // This is the guarantee that let `routes/profile.js` start passing a real `actionSource`
42
+ // (mindful-web#341) without touching a single tenant's Omeda behavior data. `profileUpdate` and
43
+ // `progressiveProfile` are mapped by no tenant, and an unmapped source must be OMITTED — it must
44
+ // never fall through to `valueIds.default` (which would silently relabel every profile save as a
45
+ // default-source action) and must never emit an undefined value id to Omeda.
46
+ //
47
+ // If a tenant later adds `valueIds.profileUpdate`, that is an opt-in and this test still holds:
48
+ // it asserts the behavior for a config that does not map the source.
49
+ ['profileUpdate', 'progressiveProfile', 'somethingNobodyMapped'].forEach((actionSource) => {
50
+ it(`omits the action source attribute for unmapped source '${actionSource}'`, () => {
51
+ const behavior = buildBehaviorFor('onUserProfileUpdate', { actionSource });
52
+ expect(behavior.id).to.equal(105);
53
+ expect(behavior.attributes.map(({ id }) => id)).to.not.include(11);
54
+ // The website attribute has a `valueId` fallback, so it still rides along.
55
+ expect(attrsById(behavior)[8]).to.equal(12345);
56
+ });
57
+ });
58
+
59
+ it('omits the action source attribute when no source is passed at all', () => {
60
+ // The pre-#341 behavior of `routes/profile.js`. Identical to the unmapped case above, which is
61
+ // precisely why passing a source is safe.
62
+ const behavior = buildBehaviorFor('onUserProfileUpdate', {});
63
+ expect(behavior.attributes.map(({ id }) => id)).to.not.include(11);
64
+ });
65
+ });
@@ -0,0 +1,256 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const {
4
+ BUCKETS,
5
+ getBucketForKey,
6
+ getBucketForSource,
7
+ normalizeSource,
8
+ } = require('../utils/conversion-source-buckets');
9
+ const resolveOptInHookConfig = require('../utils/resolve-opt-in-hook-config');
10
+
11
+ const HOOK = 'onLoginLinkSent';
12
+
13
+ describe('utils/conversion-source-buckets', () => {
14
+ describe('normalizeSource', () => {
15
+ // Expected values are `constantCase(...)` from the `change-case` package, which is what
16
+ // mindful-reporting applies when it writes the member event. Every real login-source literal
17
+ // in the fleet is listed so a regression in the regexes shows up as a named failure.
18
+ const cases = [
19
+ ['contentGate', 'CONTENT_GATE'],
20
+ ['content_meter_login', 'CONTENT_METER_LOGIN'],
21
+ ['newsletterSignup', 'NEWSLETTER_SIGNUP'],
22
+ ['recommendedSignup', 'RECOMMENDED_SIGNUP'],
23
+ ['comments', 'COMMENTS'],
24
+ ['contentAccess', 'CONTENT_ACCESS'],
25
+ ['contentDownload', 'CONTENT_DOWNLOAD'],
26
+ ['equipmentCalculator', 'EQUIPMENT_CALCULATOR'],
27
+ ['loadAnalyzer', 'LOAD_ANALYZER'],
28
+ ['pib_login', 'PIB_LOGIN'],
29
+ ['google-one-tap', 'GOOGLE_ONE_TAP'],
30
+ ['change-email', 'CHANGE_EMAIL'],
31
+ ['progressiveProfile', 'PROGRESSIVE_PROFILE'],
32
+ ['truckHistoryReport', 'TRUCK_HISTORY_REPORT'],
33
+ ['poultryTrendsLiveChartPage', 'POULTRY_TRENDS_LIVE_CHART_PAGE'],
34
+ ['default', 'DEFAULT'],
35
+ ['subscribe', 'SUBSCRIBE'],
36
+ ];
37
+
38
+ cases.forEach(([input, expected]) => {
39
+ it(`normalizes ${input} to ${expected}`, () => {
40
+ expect(normalizeSource(input)).to.equal(expected);
41
+ });
42
+ });
43
+
44
+ // The one case a naive implementation gets wrong. Reporting's key is `TOP250`; splitting on a
45
+ // letter→digit boundary would yield `TOP_250` and silently stop matching.
46
+ it('does not insert a separator between a letter and a digit', () => {
47
+ expect(normalizeSource('top250')).to.equal('TOP250');
48
+ });
49
+
50
+ it('is a no-op for already-normalized input', () => {
51
+ ['TOP250', 'CONTENT_GATE', 'GOOGLE_ONE_TAP'].forEach((v) => {
52
+ expect(normalizeSource(v)).to.equal(v);
53
+ });
54
+ });
55
+
56
+ it('handles null and undefined without throwing', () => {
57
+ expect(normalizeSource(undefined)).to.equal('');
58
+ expect(normalizeSource(null)).to.equal('');
59
+ });
60
+ });
61
+
62
+ describe('getBucketForSource', () => {
63
+ it('buckets content-gating sources', () => {
64
+ expect(getBucketForSource('contentGate')).to.equal(BUCKETS.contentGating);
65
+ expect(getBucketForSource('content_meter_login')).to.equal(BUCKETS.contentGating);
66
+ });
67
+
68
+ it('buckets newsletter sources', () => {
69
+ expect(getBucketForSource('newsletterSignup')).to.equal(BUCKETS.newsletter);
70
+ expect(getBucketForSource('recommendedSignup')).to.equal(BUCKETS.newsletter);
71
+ });
72
+
73
+ // The two counterintuitive groupings, asserted so nobody "corrects" the map.
74
+ it('buckets comments as feature gating, not other', () => {
75
+ expect(getBucketForSource('comments')).to.equal(BUCKETS.featureGating);
76
+ });
77
+
78
+ it('buckets content access/download as feature gating, not content gating', () => {
79
+ expect(getBucketForSource('contentAccess')).to.equal(BUCKETS.featureGating);
80
+ expect(getBucketForSource('contentDownload')).to.equal(BUCKETS.featureGating);
81
+ });
82
+
83
+ it('buckets google one tap as other', () => {
84
+ expect(getBucketForSource('google-one-tap')).to.equal(BUCKETS.other);
85
+ });
86
+
87
+ it('returns undefined for deliberately unbucketed sources', () => {
88
+ ['change-email', 'progressiveProfile', 'truckHistoryReport'].forEach((v) => {
89
+ expect(getBucketForSource(v)).to.equal(undefined);
90
+ });
91
+ });
92
+
93
+ it('returns undefined for unknown and empty sources', () => {
94
+ expect(getBucketForSource('somethingBrandNew')).to.equal(undefined);
95
+ expect(getBucketForSource(undefined)).to.equal(undefined);
96
+ expect(getBucketForSource('')).to.equal(undefined);
97
+ });
98
+ });
99
+
100
+ describe('getBucketForKey', () => {
101
+ it('accepts the report area names', () => {
102
+ expect(getBucketForKey('contentGating')).to.equal(BUCKETS.contentGating);
103
+ expect(getBucketForKey('featureGating')).to.equal(BUCKETS.featureGating);
104
+ expect(getBucketForKey('newsletter')).to.equal(BUCKETS.newsletter);
105
+ expect(getBucketForKey('other')).to.equal(BUCKETS.other);
106
+ });
107
+
108
+ it('accepts the plural newsletter heading shown in the UI', () => {
109
+ expect(getBucketForKey('newsletterSignups')).to.equal(BUCKETS.newsletter);
110
+ });
111
+
112
+ it('returns undefined for an exact-source key', () => {
113
+ expect(getBucketForKey('contentGate')).to.equal(undefined);
114
+ expect(getBucketForKey('google-one-tap')).to.equal(undefined);
115
+ });
116
+ });
117
+ });
118
+
119
+ describe('utils/resolve-opt-in-hook-config', () => {
120
+ const base = { demographics: [], deploymentTypeIds: [332], productIds: [366] };
121
+
122
+ it('returns undefined when the hook is not configured', () => {
123
+ expect(resolveOptInHookConfig({ optInHooks: {}, hook: HOOK, source: 'contentGate' })).to.equal(undefined);
124
+ expect(resolveOptInHookConfig({ hook: HOOK, source: 'contentGate' })).to.equal(undefined);
125
+ expect(resolveOptInHookConfig()).to.equal(undefined);
126
+ });
127
+
128
+ it('returns the base config when no overrides are declared', () => {
129
+ const resolved = resolveOptInHookConfig({
130
+ optInHooks: { [HOOK]: base },
131
+ hook: HOOK,
132
+ source: 'contentGate',
133
+ });
134
+ expect(resolved).to.deep.equal(base);
135
+ });
136
+
137
+ it('never returns the source map as payload data', () => {
138
+ const resolved = resolveOptInHookConfig({
139
+ optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
140
+ hook: HOOK,
141
+ source: 'contentGate',
142
+ });
143
+ expect(resolved).to.not.have.property('source');
144
+ });
145
+
146
+ it('applies a bucket override, per key', () => {
147
+ const resolved = resolveOptInHookConfig({
148
+ optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
149
+ hook: HOOK,
150
+ source: 'contentGate',
151
+ });
152
+ // productIds suppressed; everything else inherited.
153
+ expect(resolved).to.deep.equal({ demographics: [], deploymentTypeIds: [332], productIds: [] });
154
+ });
155
+
156
+ it('applies a bucket override to every source in that bucket', () => {
157
+ const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
158
+ ['contentGate', 'content_meter_login'].forEach((source) => {
159
+ const resolved = resolveOptInHookConfig({ optInHooks, hook: HOOK, source });
160
+ expect(resolved.productIds, `source ${source}`).to.deep.equal([]);
161
+ });
162
+ });
163
+
164
+ it('leaves sources outside the overridden bucket on the base config', () => {
165
+ const resolved = resolveOptInHookConfig({
166
+ optInHooks: { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } },
167
+ hook: HOOK,
168
+ source: 'newsletterSignup',
169
+ });
170
+ expect(resolved).to.deep.equal(base);
171
+ });
172
+
173
+ it('lets an exact-source override beat its own bucket', () => {
174
+ const optInHooks = {
175
+ [HOOK]: {
176
+ ...base,
177
+ source: {
178
+ contentGating: { productIds: [] },
179
+ contentGate: { productIds: [42] },
180
+ },
181
+ },
182
+ };
183
+ // The carve-out wins for the named source...
184
+ expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'contentGate' }).productIds).to.deep.equal([42]);
185
+ // ...while its bucket sibling still gets the bucket rule.
186
+ expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'content_meter_login' }).productIds).to.deep.equal([]);
187
+ });
188
+
189
+ it('applies an exact-source override for an unbucketed source', () => {
190
+ const resolved = resolveOptInHookConfig({
191
+ optInHooks: { [HOOK]: { ...base, source: { progressiveProfile: { productIds: [7] } } } },
192
+ hook: HOOK,
193
+ source: 'progressiveProfile',
194
+ });
195
+ expect(resolved.productIds).to.deep.equal([7]);
196
+ });
197
+
198
+ it('matches override keys regardless of casing', () => {
199
+ ['contentGate', 'CONTENT_GATE', 'content-gate'].forEach((key) => {
200
+ const resolved = resolveOptInHookConfig({
201
+ optInHooks: { [HOOK]: { ...base, source: { [key]: { productIds: [9] } } } },
202
+ hook: HOOK,
203
+ source: 'contentGate',
204
+ });
205
+ expect(resolved.productIds, `key ${key}`).to.deep.equal([9]);
206
+ });
207
+ });
208
+
209
+ it('can override several keys at once', () => {
210
+ const resolved = resolveOptInHookConfig({
211
+ optInHooks: {
212
+ [HOOK]: { ...base, source: { contentGating: { productIds: [], deploymentTypeIds: [] } } },
213
+ },
214
+ hook: HOOK,
215
+ source: 'contentGate',
216
+ });
217
+ expect(resolved).to.deep.equal({ demographics: [], deploymentTypeIds: [], productIds: [] });
218
+ });
219
+
220
+ it('does not mutate the supplied config', () => {
221
+ const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
222
+ resolveOptInHookConfig({ optInHooks, hook: HOOK, source: 'contentGate' });
223
+ expect(optInHooks[HOOK].productIds).to.deep.equal([366]);
224
+ expect(optInHooks[HOOK].source.contentGating.productIds).to.deep.equal([]);
225
+ });
226
+
227
+ it('falls back to base for an unknown, empty or missing source', () => {
228
+ const optInHooks = { [HOOK]: { ...base, source: { contentGating: { productIds: [] } } } };
229
+ [undefined, '', 'somethingBrandNew'].forEach((source) => {
230
+ expect(resolveOptInHookConfig({ optInHooks, hook: HOOK, source }), `source ${source}`).to.deep.equal(base);
231
+ });
232
+ });
233
+
234
+ it('ignores a non-object source map or override value', () => {
235
+ expect(resolveOptInHookConfig({
236
+ optInHooks: { [HOOK]: { ...base, source: 'nope' } },
237
+ hook: HOOK,
238
+ source: 'contentGate',
239
+ })).to.deep.equal(base);
240
+
241
+ expect(resolveOptInHookConfig({
242
+ optInHooks: { [HOOK]: { ...base, source: { contentGating: 'nope' } } },
243
+ hook: HOOK,
244
+ source: 'contentGate',
245
+ })).to.deep.equal(base);
246
+ });
247
+
248
+ it('resolves each hook independently', () => {
249
+ const optInHooks = {
250
+ onLoginLinkSent: { productIds: [1], source: { contentGating: { productIds: [] } } },
251
+ onAuthenticationSuccess: { productIds: [2] },
252
+ };
253
+ expect(resolveOptInHookConfig({ optInHooks, hook: 'onLoginLinkSent', source: 'contentGate' }).productIds).to.deep.equal([]);
254
+ expect(resolveOptInHookConfig({ optInHooks, hook: 'onAuthenticationSuccess', source: 'contentGate' }).productIds).to.deep.equal([2]);
255
+ });
256
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Groups IdentityX login sources into the same four "source buckets" the Insights → Audience →
3
+ * Audience Conversion report uses, so a site's `identityXOptInHooks` can override its Omeda
4
+ * appends per bucket rather than per individual source.
5
+ *
6
+ * ## Where these names come from
7
+ *
8
+ * The buckets are defined in the **reporting** repo, not here:
9
+ * `mindful-reporting/packages/audience/repo/src/repos/member-events/conversion-sources.ts`
10
+ * (consumed by `classifyConversionRow` in
11
+ * `packages/repo/src/insights/audience-captures/conversion-query.ts`). `SOURCE_TO_BUCKET` below is
12
+ * a deliberate mirror of that file's four maps — there is no package shared between mindful-web
13
+ * and mindful-reporting, so the duplication cannot be avoided.
14
+ *
15
+ * **Keep the two in sync.** If reporting adds a source to a bucket and this map isn't updated,
16
+ * that source silently falls back to a site's base hook config rather than erroring — a quiet
17
+ * wrong answer. When editing either file, edit both.
18
+ *
19
+ * ## The two vocabularies
20
+ *
21
+ * The website emits mixed casing (`contentGate`, `content_meter_login`, `google-one-tap`), while
22
+ * reporting keys on UPPER_SNAKE (`CONTENT_GATE`, `CONTENT_METER_LOGIN`, `GOOGLE_ONE_TAP`). The
23
+ * bridge is `constantCase(source)`, applied when the member event is written
24
+ * (`mindful-reporting/graphql/idx-compat/src/resolvers/app-user.ts`). `normalizeSource` reproduces
25
+ * that transform so this map can use the exact strings reporting uses.
26
+ */
27
+
28
+ /**
29
+ * Normalizes a login source to the UPPER_SNAKE form reporting stores, matching `constantCase`
30
+ * from the `change-case` package (which is not a dependency here — this is three replacements,
31
+ * not worth one).
32
+ *
33
+ * Only a lower→upper boundary introduces a separator. A letter→digit boundary must NOT:
34
+ * `constantCase('top250')` is `TOP250`, and reporting's key is `TOP250`, so a `([a-z])(\d)` rule
35
+ * would produce `TOP_250` and silently stop matching. Already-normalized input is a no-op, so
36
+ * config may be written in either vocabulary.
37
+ *
38
+ * @param {string} value
39
+ * @returns {string}
40
+ */
41
+ const normalizeSource = (value) => String(value == null ? '' : value)
42
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
43
+ .replace(/[-\s]+/g, '_')
44
+ .toUpperCase();
45
+
46
+ /** The bucket a source belongs to, mirroring reporting's `ConversionRowArea`. */
47
+ const BUCKETS = {
48
+ contentGating: 'contentGating',
49
+ featureGating: 'featureGating',
50
+ newsletter: 'newsletter',
51
+ other: 'other',
52
+ };
53
+
54
+ /**
55
+ * Normalized login source → bucket. Mirrors, in order, `CONTENT_GATING_SOURCE_TO_NAME`,
56
+ * `NEWSLETTER_SOURCES`, `FEATURE_GATING_SOURCE_TO_NAME` and `OTHER_SOURCE_TO_NAME` from
57
+ * reporting's `conversion-sources.ts`.
58
+ *
59
+ * Two groupings routinely surprise people, and both are deliberate upstream:
60
+ * - `COMMENTS` is **feature** gating, not `other`.
61
+ * - `CONTENT_ACCESS` / `CONTENT_DOWNLOAD` are **feature** gating, not `contentGating` — they gate
62
+ * an asset rather than an article, and carry no p1-events Views signal.
63
+ *
64
+ * Sources deliberately absent (no bucket in the report, so reachable only by an exact-source
65
+ * override key): `CHANGE_EMAIL`, `PROGRESSIVE_PROFILE`, `TRUCK_HISTORY_REPORT`,
66
+ * `POULTRY_TRENDS_LIVE_CHART_PAGE`.
67
+ */
68
+ const SOURCE_TO_BUCKET = {
69
+ // Content gating — the only areas with a Views signal, hence a real conversion rate.
70
+ CONTENT_GATE: BUCKETS.contentGating,
71
+ CONTENT_METER_LOGIN: BUCKETS.contentGating,
72
+
73
+ // Newsletter widgets. "Recommended" uses its own source rather than NEWSLETTER_SIGNUP.
74
+ NEWSLETTER_SIGNUP: BUCKETS.newsletter,
75
+ RECOMMENDED_SIGNUP: BUCKETS.newsletter,
76
+
77
+ // Feature gating — page/tool gates.
78
+ COMMENTS: BUCKETS.featureGating,
79
+ CONTACT_COMPANY: BUCKETS.featureGating,
80
+ CONTENT_ACCESS: BUCKETS.featureGating,
81
+ CONTENT_DOWNLOAD: BUCKETS.featureGating,
82
+ EQUIPMENT_CALCULATOR: BUCKETS.featureGating,
83
+ INGREDIENT_ISSUES: BUCKETS.featureGating,
84
+ LOAD_ANALYZER: BUCKETS.featureGating,
85
+ PIB_LOGIN: BUCKETS.featureGating,
86
+ TOP250: BUCKETS.featureGating,
87
+
88
+ // Other / catch-all.
89
+ DEFAULT: BUCKETS.other,
90
+ GOOGLE_ONE_TAP: BUCKETS.other,
91
+ IDX_API: BUCKETS.other,
92
+ SUBSCRIBE: BUCKETS.other,
93
+ };
94
+
95
+ /**
96
+ * Normalized override key → bucket. Accepts the report's internal area name and, for the
97
+ * newsletter bucket, the plural heading the UI actually displays ("Newsletter Signups"), since
98
+ * that is what a config author reading the report is most likely to type.
99
+ */
100
+ const BUCKET_KEY_TO_BUCKET = {
101
+ CONTENT_GATING: BUCKETS.contentGating,
102
+ FEATURE_GATING: BUCKETS.featureGating,
103
+ NEWSLETTER: BUCKETS.newsletter,
104
+ NEWSLETTER_SIGNUPS: BUCKETS.newsletter,
105
+ OTHER: BUCKETS.other,
106
+ };
107
+
108
+ /**
109
+ * Returns the bucket for a login source, or `undefined` when the source has no bucket (unknown,
110
+ * empty, or one of the deliberately unbucketed values above).
111
+ *
112
+ * @param {string} [source]
113
+ * @returns {string|undefined}
114
+ */
115
+ const getBucketForSource = (source) => {
116
+ if (!source) return undefined;
117
+ return SOURCE_TO_BUCKET[normalizeSource(source)];
118
+ };
119
+
120
+ /**
121
+ * Returns the bucket an override key refers to, or `undefined` when the key is not a bucket name
122
+ * (i.e. it should be treated as an exact source key).
123
+ *
124
+ * @param {string} [key]
125
+ * @returns {string|undefined}
126
+ */
127
+ const getBucketForKey = (key) => {
128
+ if (!key) return undefined;
129
+ return BUCKET_KEY_TO_BUCKET[normalizeSource(key)];
130
+ };
131
+
132
+ module.exports = {
133
+ BUCKETS,
134
+ BUCKET_KEY_TO_BUCKET,
135
+ SOURCE_TO_BUCKET,
136
+ getBucketForKey,
137
+ getBucketForSource,
138
+ normalizeSource,
139
+ };
@@ -0,0 +1,89 @@
1
+ const { getBucketForKey, getBucketForSource, normalizeSource } = require('./conversion-source-buckets');
2
+
3
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
4
+
5
+ /**
6
+ * Resolves a site's `identityXOptInHooks[hook]` config for one login source, applying any
7
+ * per-source overrides declared under that hook's `source` key.
8
+ *
9
+ * ## Config shape
10
+ *
11
+ * ```js
12
+ * // sites/<site>/config/identity-x-opt-in-hooks.js
13
+ * onAuthenticationSuccess: {
14
+ * productIds: [83, 88, 84],
15
+ * deploymentTypeIds: [332],
16
+ * source: {
17
+ * contentGating: { productIds: [] }, // both content gates: no products, keep deployments
18
+ * contentGate: { productIds: [42] }, // ...except this one specifically
19
+ * },
20
+ * }
21
+ * ```
22
+ *
23
+ * Override keys may be either a **bucket** (`contentGating`, `featureGating`, `newsletter` /
24
+ * `newsletterSignups`, `other`) or an **exact login source** (`contentGate`,
25
+ * `content_meter_login`, `google-one-tap`, …), in any casing — keys are normalized before
26
+ * comparison, so `contentGate`, `content_meter_login` and `CONTENT_GATE` all work. See
27
+ * `./conversion-source-buckets` for which sources belong to which bucket (`COMMENTS` is feature
28
+ * gating, not other; `CONTENT_ACCESS`/`CONTENT_DOWNLOAD` are feature gating, not content gating).
29
+ *
30
+ * ## Layering
31
+ *
32
+ * `base → bucket override → exact-source override`, each a shallow **per-key replace**: only the
33
+ * keys present in an override replace the base, and unspecified keys inherit. That is what makes
34
+ * `productIds: []` express "no products for this source" while leaving `deploymentTypeIds` and
35
+ * `demographics` alone. An empty array is truthy, so the consuming formatters' existing
36
+ * `(productIds || []).reduce(...)` produces no appends without any change on their side.
37
+ *
38
+ * An exact-source key always wins over the bucket that contains it, so a bucket-wide rule can be
39
+ * carved out for one source. Bucket names and source names do not currently collide.
40
+ *
41
+ * ## Degradation
42
+ *
43
+ * The set of login sources is **open** — any site template can introduce a new `source="..."` —
44
+ * so an unrecognized, empty or `undefined` source is not an error: it resolves to the base config.
45
+ * A non-object `source` value is ignored the same way.
46
+ *
47
+ * Note the three hooks name this value differently in their formatter args: `onLoginLinkSent`
48
+ * receives `source`, `onAuthenticationSuccess` receives `loginSource`, and `onUserProfileUpdate`
49
+ * receives `actionSource`. Pass whichever local holds the value.
50
+ *
51
+ * `actionSource` carries the surface that saved the profile — `profileUpdate` from
52
+ * `routes/profile.js`, `progressiveProfile` from `routes/progressive-profile.js`, or whatever a
53
+ * client put in `additionalEventData`. Neither route value belongs to a conversion bucket, so on
54
+ * that hook only an exact-source key can match.
55
+ *
56
+ * @param {object} params
57
+ * @param {object} [params.optInHooks] The site's `identityXOptInHooks` config.
58
+ * @param {string} params.hook One of `onLoginLinkSent`, `onAuthenticationSuccess`,
59
+ * `onUserProfileUpdate`.
60
+ * @param {string} [params.source] The login source for this request.
61
+ * @returns {object|undefined} The resolved config (without its `source` map), or `undefined` when
62
+ * the hook is not configured — so callers can keep their existing early bail.
63
+ */
64
+ module.exports = ({ optInHooks, hook, source } = {}) => {
65
+ const hookConfig = isPlainObject(optInHooks) ? optInHooks[hook] : undefined;
66
+ if (!isPlainObject(hookConfig)) return undefined;
67
+
68
+ const { source: overrides, ...base } = hookConfig;
69
+ if (!isPlainObject(overrides) || !source) return base;
70
+
71
+ const bucket = getBucketForSource(source);
72
+ const normalizedSource = normalizeSource(source);
73
+
74
+ // A single pass over the override keys, so one key cannot be applied twice and the two layers
75
+ // stay independent of the order the config happens to declare them in.
76
+ const layers = Object.keys(overrides).reduce((acc, key) => {
77
+ const value = overrides[key];
78
+ if (!isPlainObject(value)) return acc;
79
+ const keyBucket = getBucketForKey(key);
80
+ if (keyBucket) {
81
+ if (keyBucket === bucket) acc.bucket.push(value);
82
+ return acc;
83
+ }
84
+ if (normalizeSource(key) === normalizedSource) acc.exact.push(value);
85
+ return acc;
86
+ }, { bucket: [], exact: [] });
87
+
88
+ return [...layers.bucket, ...layers.exact].reduce((acc, layer) => ({ ...acc, ...layer }), base);
89
+ };