@mindful-web/marko-web-omeda-identity-x 1.85.6 → 1.87.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/components/identify.marko.js +2 -2
- package/integration-hooks/on-authentication-success.js +12 -1
- package/integration-hooks/on-login-link-sent.js +24 -16
- package/integration-hooks/on-user-profile-update.js +12 -1
- package/middleware/rapid-identify.js +2 -0
- package/package.json +3 -3
- package/rapid-identify.js +69 -19
- package/test/integration-hooks.spec.js +82 -0
- package/test/rapid-identify.spec.js +132 -1
- package/utils/get-create-field-ids.js +15 -0
- package/validation/schemas/append-subscription.js +7 -0
- package/validation/schemas/deployment-type.js +6 -0
- package/validation/schemas/index.js +4 -0
|
@@ -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.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-omeda-identity-x$1.87.0/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.
|
|
54
|
+
id: "/@mindful-web/marko-web-omeda-identity-x$1.87.0/components/identify.marko",
|
|
55
55
|
component: "./identify.marko",
|
|
56
56
|
tags: [
|
|
57
57
|
"@mindful-web/marko-web-identity-x/components/identify.marko",
|
|
@@ -2,6 +2,7 @@ const Joi = require('@parameter1/joi');
|
|
|
2
2
|
const { validate } = require('@parameter1/joi/utils');
|
|
3
3
|
const olyticsCookie = require('@mindful-web/marko-web-omeda/olytics/customer-cookie');
|
|
4
4
|
const extractPromoCode = require('../utils/extract-promo-code');
|
|
5
|
+
const getCreateFieldIds = require('../utils/get-create-field-ids');
|
|
5
6
|
const findEncryptedId = require('../external-id/find-encrypted-customer-id');
|
|
6
7
|
const props = require('../validation/props');
|
|
7
8
|
const schemas = require('../validation/schemas');
|
|
@@ -25,6 +26,7 @@ module.exports = async (params = {}) => {
|
|
|
25
26
|
promoCode: hookDataPromoCode,
|
|
26
27
|
res,
|
|
27
28
|
req,
|
|
29
|
+
service,
|
|
28
30
|
user,
|
|
29
31
|
} = validate(Joi.object({
|
|
30
32
|
appendBehaviors: Joi.array().items(schemas.appendBehavior).default([]),
|
|
@@ -33,6 +35,7 @@ module.exports = async (params = {}) => {
|
|
|
33
35
|
behavior: schemas.behavior.required(),
|
|
34
36
|
brandKey: props.brandKey.required(),
|
|
35
37
|
formatter: Joi.function().required(),
|
|
38
|
+
service: Joi.object().required(),
|
|
36
39
|
user: Joi.object().required(),
|
|
37
40
|
res: Joi.object().required(),
|
|
38
41
|
req: Joi.object().required(),
|
|
@@ -60,5 +63,13 @@ module.exports = async (params = {}) => {
|
|
|
60
63
|
appendPromoCodes,
|
|
61
64
|
},
|
|
62
65
|
});
|
|
63
|
-
await idxOmedaRapidIdentify(
|
|
66
|
+
await idxOmedaRapidIdentify({
|
|
67
|
+
...payload,
|
|
68
|
+
// Authenticating is not a general preference change: only answers for fields the
|
|
69
|
+
// registration/login form actually presents may become Omeda writes (this is where a
|
|
70
|
+
// brand-new user's registration checkbox choices land, post-verification). Everything else on
|
|
71
|
+
// the stored user — most dangerously idx-compat's channel-mirror booleans — is state, not a
|
|
72
|
+
// statement. Applied outside the formatter so no site-level formatter can drop it.
|
|
73
|
+
restrictAnswersToFieldIds: getCreateFieldIds(service.config),
|
|
74
|
+
});
|
|
64
75
|
};
|
|
@@ -2,6 +2,7 @@ const Joi = require('@parameter1/joi');
|
|
|
2
2
|
const { validate } = require('@parameter1/joi/utils');
|
|
3
3
|
const generateRequiredFieldPayload = require('@mindful-web/marko-web-identity-x/utils/generate-required-field-payload');
|
|
4
4
|
const extractPromoCode = require('../utils/extract-promo-code');
|
|
5
|
+
const getCreateFieldIds = require('../utils/get-create-field-ids');
|
|
5
6
|
const schemas = require('../validation/schemas');
|
|
6
7
|
const {
|
|
7
8
|
getAnsweredQuestionMap,
|
|
@@ -66,23 +67,30 @@ module.exports = async (params = {}) => {
|
|
|
66
67
|
|
|
67
68
|
const [omedaLinkedFields, { encryptedCustomerId }] = await Promise.all([
|
|
68
69
|
getOmedaLinkedFields({ identityX, brandKey }),
|
|
69
|
-
idxOmedaRapidIdentify(
|
|
70
|
-
...
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
idxOmedaRapidIdentify({
|
|
71
|
+
...(await formatter({
|
|
72
|
+
...params, // Pass all params through to the formatter
|
|
73
|
+
req,
|
|
74
|
+
source,
|
|
75
|
+
payload: {
|
|
76
|
+
user: user.verified ? user : {
|
|
77
|
+
...(requiredFieldKeys.reduce((obj, key) => ({ ...obj, [key]: user[key] }), {})),
|
|
78
|
+
id: user.id,
|
|
79
|
+
email: user.email,
|
|
80
|
+
},
|
|
81
|
+
behavior,
|
|
82
|
+
promoCode,
|
|
83
|
+
appendBehaviors,
|
|
84
|
+
appendDemographics,
|
|
85
|
+
appendPromoCodes,
|
|
78
86
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
87
|
+
})),
|
|
88
|
+
// Sending a login link is not a general preference change: only answers for fields the
|
|
89
|
+
// registration/login form actually presents may become Omeda writes. Everything else on the
|
|
90
|
+
// stored user — most dangerously idx-compat's channel-mirror booleans — is state, not a
|
|
91
|
+
// statement. Applied outside the formatter so no site-level formatter can drop it.
|
|
92
|
+
restrictAnswersToFieldIds: getCreateFieldIds(identityX.config),
|
|
93
|
+
}),
|
|
86
94
|
]);
|
|
87
95
|
|
|
88
96
|
const answers = getAnsweredQuestionMap(user);
|
|
@@ -20,6 +20,7 @@ module.exports = async (params = {}) => {
|
|
|
20
20
|
omedaPromoCodeDefault,
|
|
21
21
|
promoCode: hookDataPromoCode,
|
|
22
22
|
req,
|
|
23
|
+
submittedFieldIds,
|
|
23
24
|
user,
|
|
24
25
|
} = validate(Joi.object({
|
|
25
26
|
appendBehaviors: Joi.array().items(schemas.appendBehavior).default([]),
|
|
@@ -32,6 +33,10 @@ module.exports = async (params = {}) => {
|
|
|
32
33
|
omedaPromoCodeDefault: Joi.string(),
|
|
33
34
|
promoCode: Joi.string(),
|
|
34
35
|
req: Joi.object().required(),
|
|
36
|
+
// The custom field ids the invoking route actually accepted from the submitted form
|
|
37
|
+
// (profile + progressive-profile routes provide it). Absent = an older/other invoker:
|
|
38
|
+
// no restriction, current behavior.
|
|
39
|
+
submittedFieldIds: Joi.array().items(Joi.string()),
|
|
35
40
|
user: Joi.object().required(),
|
|
36
41
|
}).unknown(), params);
|
|
37
42
|
|
|
@@ -55,5 +60,11 @@ module.exports = async (params = {}) => {
|
|
|
55
60
|
},
|
|
56
61
|
});
|
|
57
62
|
|
|
58
|
-
return idxOmedaRapidIdentify(
|
|
63
|
+
return idxOmedaRapidIdentify({
|
|
64
|
+
...payload,
|
|
65
|
+
// A profile-style save syncs only the answers its form submitted; everything else on the
|
|
66
|
+
// stored user is state, not a statement. Applied outside the formatter so no site-level
|
|
67
|
+
// formatter can drop it. Absent submittedFieldIds (an older/other invoker) = unrestricted.
|
|
68
|
+
...(submittedFieldIds && { restrictAnswersToFieldIds: submittedFieldIds }),
|
|
69
|
+
});
|
|
59
70
|
};
|
|
@@ -20,6 +20,7 @@ module.exports = ({
|
|
|
20
20
|
const handler = async ({
|
|
21
21
|
user,
|
|
22
22
|
promoCode,
|
|
23
|
+
restrictAnswersToFieldIds,
|
|
23
24
|
deploymentTypes,
|
|
24
25
|
appendBehaviors,
|
|
25
26
|
appendDemographics,
|
|
@@ -30,6 +31,7 @@ module.exports = ({
|
|
|
30
31
|
brandKey,
|
|
31
32
|
productId,
|
|
32
33
|
appUser: user,
|
|
34
|
+
restrictAnswersToFieldIds,
|
|
33
35
|
promoCode: extractPromoCode({
|
|
34
36
|
promoCode,
|
|
35
37
|
omedaPromoCodeCookieName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindful-web/marko-web-omeda-identity-x",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.87.0",
|
|
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.
|
|
16
|
+
"@mindful-web/marko-web-identity-x": "^1.87.0",
|
|
17
17
|
"@mindful-web/marko-web-omeda": "^1.85.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": "
|
|
36
|
+
"gitHead": "ff4b26653f19375680d94b56997484a7dab24f05"
|
|
37
37
|
}
|
package/rapid-identify.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
const gql = require('graphql-tag');
|
|
2
|
+
const Joi = require('@parameter1/joi');
|
|
3
|
+
const { validate } = require('@parameter1/joi/utils');
|
|
2
4
|
const { get, getAsArray } = require('@mindful-web/object-path');
|
|
3
5
|
const isOmedaDemographicId = require('./external-id/is-demographic-id');
|
|
4
6
|
const isDeploymentTypeId = require('./external-id/is-deployment-type-id');
|
|
5
7
|
const isProductId = require('./external-id/is-product-id');
|
|
8
|
+
const props = require('./validation/props');
|
|
9
|
+
const schemas = require('./validation/schemas');
|
|
6
10
|
|
|
7
11
|
const ALPHA3_CODE = gql`
|
|
8
12
|
query GetAlpha3Code($alpha2: String!) {
|
|
@@ -26,28 +30,60 @@ const getAlpha3CodeFor = async (alpha2, identityX) => {
|
|
|
26
30
|
* @param {object} params.appUser The IdentityX user
|
|
27
31
|
* @param {string} [params.promoCode] An optional code to send to Omeda for tracking the
|
|
28
32
|
* acquisition source
|
|
33
|
+
* @param {Array<string|number>} [params.restrictAnswersToFieldIds] When provided, custom field
|
|
34
|
+
* answers (select, text, boolean) are mapped into the Omeda
|
|
35
|
+
* payload ONLY for fields whose id is in this set — i.e. the
|
|
36
|
+
* fields the current interaction's form actually presents.
|
|
37
|
+
* Set on the login paths (link-sent, authentication) to the
|
|
38
|
+
* site's create-field rows: idx-compat surfaces every
|
|
39
|
+
* email/magazine channel as an always-answered boolean whose
|
|
40
|
+
* "answer" mirrors stored subscription state, so pushing the
|
|
41
|
+
* full stored user unsubscribed Omeda customers on every login
|
|
42
|
+
* (the 2026-08 incident). Registration-form fields still flow
|
|
43
|
+
* with their real values — including an explicit `false`
|
|
44
|
+
* checkbox. Contact fields, behaviors, and append* hook data
|
|
45
|
+
* are never restricted. Omit (or pass null) for
|
|
46
|
+
* form-submitting callers (profile), whose payload is already
|
|
47
|
+
* scoped by their route.
|
|
29
48
|
* @param {IdentityX} params.identityX The Marko web IdentityX service
|
|
30
49
|
* @param {function} params.omedaRapidIdentify The Omeda rapid identifcation action
|
|
31
50
|
*/
|
|
32
|
-
module.exports = async ({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
module.exports = async (params = {}) => {
|
|
52
|
+
// The payload contract is enforced here — the single choke point every rapid identification
|
|
53
|
+
// flows through — so an inconsistent payload (a mistyped key, a Set where an array belongs, a
|
|
54
|
+
// malformed append entry) fails loudly instead of silently widening or narrowing what gets
|
|
55
|
+
// written to Omeda. Unknown keys are rejected: the middleware handler passes an explicit key
|
|
56
|
+
// list, so anything else arriving here is a wiring bug.
|
|
57
|
+
const {
|
|
58
|
+
brandKey,
|
|
59
|
+
productId,
|
|
60
|
+
appUser,
|
|
61
|
+
promoCode,
|
|
62
|
+
restrictAnswersToFieldIds,
|
|
63
|
+
deploymentTypes,
|
|
64
|
+
appendBehaviors,
|
|
65
|
+
appendDemographics,
|
|
66
|
+
appendPromoCodes,
|
|
67
|
+
appendSubscriptions,
|
|
68
|
+
behavior,
|
|
69
|
+
identityX,
|
|
70
|
+
omedaRapidIdentify,
|
|
71
|
+
} = validate(Joi.object({
|
|
72
|
+
brandKey: props.brandKey.required(),
|
|
73
|
+
productId: Joi.number(),
|
|
74
|
+
appUser: Joi.object({ email: Joi.string().required() }).unknown(true).required(),
|
|
75
|
+
promoCode: Joi.string().allow('', null),
|
|
76
|
+
restrictAnswersToFieldIds: Joi.array()
|
|
77
|
+
.items(Joi.alternatives().try(Joi.string(), Joi.number())).allow(null),
|
|
78
|
+
deploymentTypes: Joi.array().items(schemas.deploymentType).default([]),
|
|
79
|
+
appendBehaviors: Joi.array().items(schemas.appendBehavior),
|
|
80
|
+
appendDemographics: Joi.array().items(schemas.appendDemographic),
|
|
81
|
+
appendPromoCodes: Joi.array().items(schemas.appendPromoCode),
|
|
82
|
+
appendSubscriptions: Joi.array().items(schemas.appendSubscription),
|
|
83
|
+
behavior: schemas.behavior,
|
|
84
|
+
identityX: Joi.object().required(),
|
|
85
|
+
omedaRapidIdentify: Joi.function().required(),
|
|
86
|
+
}), params);
|
|
51
87
|
const {
|
|
52
88
|
givenName,
|
|
53
89
|
familyName,
|
|
@@ -70,9 +106,18 @@ module.exports = async ({
|
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
const behaviors = (behavior) ? [{ id: behavior.id, attributes: getAsArray(behavior, 'attributes') }] : [];
|
|
109
|
+
|
|
110
|
+
// Fields the current interaction's form presents. Answers for anything else are stored state,
|
|
111
|
+
// not something the user just said — never rebroadcast them as writes.
|
|
112
|
+
const allowedFieldIds = restrictAnswersToFieldIds == null
|
|
113
|
+
? null
|
|
114
|
+
: new Set([...restrictAnswersToFieldIds].map((id) => `${id}`));
|
|
115
|
+
const isFieldAllowed = (field) => !allowedFieldIds || allowedFieldIds.has(`${field.id}`);
|
|
116
|
+
|
|
73
117
|
const demographics = getAsArray(appUser, 'customSelectFieldAnswers').filter((select) => {
|
|
74
118
|
const { field, hasAnswered } = select;
|
|
75
119
|
if (!field.active || !field.externalId || !hasAnswered) return false;
|
|
120
|
+
if (!isFieldAllowed(field)) return false;
|
|
76
121
|
return isOmedaDemographicId({ externalId: field.externalId, brandKey })
|
|
77
122
|
&& select.answers.some((answer) => answer.externalIdentifier);
|
|
78
123
|
}).map((select) => {
|
|
@@ -90,6 +135,7 @@ module.exports = async ({
|
|
|
90
135
|
const { field, hasAnswered } = text;
|
|
91
136
|
const { externalId } = field;
|
|
92
137
|
if (!field.active || !externalId || !hasAnswered) return;
|
|
138
|
+
if (!isFieldAllowed(field)) return;
|
|
93
139
|
|
|
94
140
|
const { identifier } = field.externalId;
|
|
95
141
|
const id = parseInt(identifier.value, 10);
|
|
@@ -103,6 +149,10 @@ module.exports = async ({
|
|
|
103
149
|
const { field, hasAnswered } = boolean;
|
|
104
150
|
const { externalId } = field;
|
|
105
151
|
if (!field.active || !externalId || !hasAnswered) return;
|
|
152
|
+
// Channel-mirror booleans (idx-compat surfaces every email/magazine channel as an
|
|
153
|
+
// always-answered boolean of stored subscription state) are never form fields, so on scoped
|
|
154
|
+
// calls they can never become deployment opt-outs or `Receive: 0` subscription writes.
|
|
155
|
+
if (!isFieldAllowed(field)) return;
|
|
106
156
|
|
|
107
157
|
const { identifier } = field.externalId;
|
|
108
158
|
const id = parseInt(identifier.value, 10);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { describe, it } = require('mocha');
|
|
2
|
+
const { expect } = require('chai');
|
|
3
|
+
const onAuthenticationSuccess = require('../integration-hooks/on-authentication-success');
|
|
4
|
+
const onUserProfileUpdate = require('../integration-hooks/on-user-profile-update');
|
|
5
|
+
|
|
6
|
+
const BRAND = 'athlcd';
|
|
7
|
+
|
|
8
|
+
const encryptedExternalId = () => ({
|
|
9
|
+
identifier: { type: 'encrypted', value: '0240G4865912F6U' },
|
|
10
|
+
namespace: { provider: 'omeda', tenant: BRAND, type: 'customer' },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const res = () => ({
|
|
14
|
+
cookie: () => {},
|
|
15
|
+
req: { cookies: {}, hostname: 'www.example.com', get: () => 'www.example.com' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// The registration form presents one custom boolean (a watt-style login checkbox) and one
|
|
19
|
+
// custom select; built-ins never contribute to the answer scope.
|
|
20
|
+
const service = () => ({
|
|
21
|
+
config: {
|
|
22
|
+
getRequiredCreateFieldRows: () => [
|
|
23
|
+
[{ type: 'custom-boolean', id: 'form-newsletter-optin' }, { type: 'built-in', key: 'givenName', required: true }],
|
|
24
|
+
[{ type: 'custom-select', id: 'form-job-function' }],
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('integration-hooks/on-authentication-success', () => {
|
|
30
|
+
it('scopes answers to the create-form field ids, outside the formatter', async () => {
|
|
31
|
+
let captured;
|
|
32
|
+
await onAuthenticationSuccess({
|
|
33
|
+
behavior: { id: 9 },
|
|
34
|
+
brandKey: BRAND,
|
|
35
|
+
// A formatter that rebuilds the payload from scratch must not be able to drop the scope.
|
|
36
|
+
formatter: async () => ({ user: { email: 'someone@example.com' } }),
|
|
37
|
+
idxOmedaRapidIdentify: async (payload) => { captured = payload; },
|
|
38
|
+
omedaPromoCodeCookieName: 'omeda_promo_code',
|
|
39
|
+
res: res(),
|
|
40
|
+
req: {},
|
|
41
|
+
service: service(),
|
|
42
|
+
user: { verified: true, email: 'someone@example.com', externalIds: [encryptedExternalId()] },
|
|
43
|
+
});
|
|
44
|
+
expect([...captured.restrictAnswersToFieldIds].sort())
|
|
45
|
+
.to.deep.equal(['form-job-function', 'form-newsletter-optin']);
|
|
46
|
+
expect(captured.user).to.deep.equal({ email: 'someone@example.com' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('integration-hooks/on-user-profile-update', () => {
|
|
51
|
+
const harness = () => {
|
|
52
|
+
let captured;
|
|
53
|
+
return {
|
|
54
|
+
params: {
|
|
55
|
+
behavior: { id: 10 },
|
|
56
|
+
// A formatter that rebuilds the payload from scratch must not be able to drop the scope.
|
|
57
|
+
formatter: async () => ({ user: { email: 'someone@example.com' } }),
|
|
58
|
+
idxOmedaRapidIdentify: async (payload) => { captured = payload; },
|
|
59
|
+
omedaPromoCodeCookieName: 'omeda_promo_code',
|
|
60
|
+
req: { cookies: {} },
|
|
61
|
+
user: { email: 'someone@example.com' },
|
|
62
|
+
},
|
|
63
|
+
captured: () => captured,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
it('forwards the route-provided submittedFieldIds as the answer scope, outside the formatter', async () => {
|
|
68
|
+
const h = harness();
|
|
69
|
+
await onUserProfileUpdate({
|
|
70
|
+
...h.params,
|
|
71
|
+
submittedFieldIds: ['form-job-function', 'chan-ab-magazine'],
|
|
72
|
+
});
|
|
73
|
+
expect(h.captured().restrictAnswersToFieldIds)
|
|
74
|
+
.to.deep.equal(['form-job-function', 'chan-ab-magazine']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('applies no restriction when the invoker provides none (back-compat)', async () => {
|
|
78
|
+
const h = harness();
|
|
79
|
+
await onUserProfileUpdate(h.params);
|
|
80
|
+
expect(h.captured()).to.not.have.property('restrictAnswersToFieldIds');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -38,7 +38,24 @@ const deploymentBooleanAnswer = ({ id = 'chan-2', deploymentTypeId = 17, answer
|
|
|
38
38
|
},
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const demographicBooleanAnswer = ({ id = 'demo-1', demographicId = 55, answer = false } = {}) => ({
|
|
42
|
+
id,
|
|
43
|
+
hasAnswered: true,
|
|
44
|
+
answer,
|
|
45
|
+
value: answer,
|
|
46
|
+
field: {
|
|
47
|
+
id,
|
|
48
|
+
label: 'Do you purchase?',
|
|
49
|
+
active: true,
|
|
50
|
+
externalId: {
|
|
51
|
+
id: `omeda.demographic.${BRAND}*${demographicId}`,
|
|
52
|
+
namespace: { provider: 'omeda', tenant: BRAND, type: 'demographic' },
|
|
53
|
+
identifier: { value: `${demographicId}`, type: null },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const run = async (appUser, opts = {}) => {
|
|
42
59
|
let captured;
|
|
43
60
|
await idxOmedaRapidIdentify({
|
|
44
61
|
brandKey: BRAND,
|
|
@@ -46,6 +63,7 @@ const run = async (appUser) => {
|
|
|
46
63
|
appUser: { email: 'someone@example.com', ...appUser },
|
|
47
64
|
identityX: { addExternalUserId: async () => ({}) },
|
|
48
65
|
omedaRapidIdentify: async (payload) => { captured = payload; return { id: 1, encryptedCustomerId: 'x' }; },
|
|
66
|
+
...opts,
|
|
49
67
|
});
|
|
50
68
|
return captured;
|
|
51
69
|
};
|
|
@@ -79,4 +97,117 @@ describe('rapid-identify (idx → omeda payload)', () => {
|
|
|
79
97
|
expect(payload).to.not.have.property('subscriptions');
|
|
80
98
|
expect(payload).to.not.have.property('deploymentTypes');
|
|
81
99
|
});
|
|
100
|
+
|
|
101
|
+
describe('with restrictAnswersToFieldIds (form-scoped answers)', () => {
|
|
102
|
+
it('drops answers — true AND false — for fields outside the scope (channel-mirror booleans)', async () => {
|
|
103
|
+
await Promise.all([false, true].map(async (answer) => {
|
|
104
|
+
const payload = await run({
|
|
105
|
+
customBooleanFieldAnswers: [
|
|
106
|
+
productBooleanAnswer({ id: 'chan-1', answer }),
|
|
107
|
+
deploymentBooleanAnswer({ id: 'chan-2', answer }),
|
|
108
|
+
],
|
|
109
|
+
}, { restrictAnswersToFieldIds: ['some-form-field'] });
|
|
110
|
+
expect(payload).to.not.have.property('subscriptions');
|
|
111
|
+
expect(payload).to.not.have.property('deploymentTypes');
|
|
112
|
+
}));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps in-scope boolean answers with their real value — an explicit false checkbox flows', async () => {
|
|
116
|
+
const payload = await run({
|
|
117
|
+
customBooleanFieldAnswers: [
|
|
118
|
+
productBooleanAnswer({ id: 'form-product', answer: false }),
|
|
119
|
+
deploymentBooleanAnswer({ id: 'form-newsletter', answer: true }),
|
|
120
|
+
],
|
|
121
|
+
}, { restrictAnswersToFieldIds: ['form-product', 'form-newsletter'] });
|
|
122
|
+
expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: false }]);
|
|
123
|
+
expect(payload.deploymentTypes).to.deep.equal([{ id: 17, optedIn: true }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('scopes boolean demographics too, but an in-scope false demographic still writes', async () => {
|
|
127
|
+
const payload = await run({
|
|
128
|
+
customBooleanFieldAnswers: [
|
|
129
|
+
demographicBooleanAnswer({ id: 'form-demo', answer: false }),
|
|
130
|
+
demographicBooleanAnswer({ id: 'other-demo', demographicId: 56, answer: true }),
|
|
131
|
+
],
|
|
132
|
+
}, { restrictAnswersToFieldIds: ['form-demo'] });
|
|
133
|
+
expect(payload.demographics).to.deep.equal([{ id: 55, values: ['false'] }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('scopes select answers: in-scope maps to demographics, out-of-scope is dropped', async () => {
|
|
137
|
+
const selectAnswer = (fieldId, demographicId, valueId) => ({
|
|
138
|
+
id: fieldId,
|
|
139
|
+
hasAnswered: true,
|
|
140
|
+
answers: [{ id: `opt-${valueId}`, externalIdentifier: `${valueId}` }],
|
|
141
|
+
field: {
|
|
142
|
+
id: fieldId,
|
|
143
|
+
active: true,
|
|
144
|
+
externalId: {
|
|
145
|
+
id: `omeda.demographic.${BRAND}*${demographicId}`,
|
|
146
|
+
namespace: { provider: 'omeda', tenant: BRAND, type: 'demographic' },
|
|
147
|
+
identifier: { value: `${demographicId}`, type: null },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const payload = await run({
|
|
152
|
+
customSelectFieldAnswers: [
|
|
153
|
+
selectAnswer('form-select', 68, 361),
|
|
154
|
+
selectAnswer('profile-only-select', 69, 374),
|
|
155
|
+
],
|
|
156
|
+
}, { restrictAnswersToFieldIds: ['form-select'] });
|
|
157
|
+
expect(payload.demographics).to.deep.equal([{ id: 68, values: ['361'] }]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('applies no restriction when omitted or null (back-compat for form-submitting callers)', async () => {
|
|
161
|
+
await Promise.all([{}, { restrictAnswersToFieldIds: null }].map(async (opts) => {
|
|
162
|
+
const payload = await run({
|
|
163
|
+
customBooleanFieldAnswers: [productBooleanAnswer({ answer: false })],
|
|
164
|
+
}, opts);
|
|
165
|
+
expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: false }]);
|
|
166
|
+
}));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('compares ids as strings (numeric config ids match string field ids)', async () => {
|
|
170
|
+
const payload = await run({
|
|
171
|
+
customBooleanFieldAnswers: [productBooleanAnswer({ id: '123', answer: true })],
|
|
172
|
+
}, { restrictAnswersToFieldIds: [123] });
|
|
173
|
+
expect(payload.subscriptions).to.deep.equal([{ id: 28, receive: true }]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('payload validation (the contract is enforced, not assumed)', () => {
|
|
178
|
+
const expectRejects = async (opts, match) => {
|
|
179
|
+
try {
|
|
180
|
+
await run({}, opts);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
expect(e.message).to.match(match);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
throw new Error('expected the payload to be rejected');
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
it('rejects unknown keys — a mistyped restriction key must fail loudly, not mean "unrestricted"', async () => {
|
|
189
|
+
await expectRejects({ restrictAnswerToFieldIds: ['x'] }, /restrictAnswerToFieldIds/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects a Set for restrictAnswersToFieldIds (the contract is array-only)', async () => {
|
|
193
|
+
await expectRejects({ restrictAnswersToFieldIds: new Set(['x']) }, /restrictAnswersToFieldIds/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('rejects malformed append entries', async () => {
|
|
197
|
+
await expectRejects({ appendSubscriptions: [{ id: 28 }] }, /receive/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('requires appUser.email and the identifier function', async () => {
|
|
201
|
+
let threw = false;
|
|
202
|
+
try {
|
|
203
|
+
await idxOmedaRapidIdentify({
|
|
204
|
+
brandKey: BRAND,
|
|
205
|
+
appUser: {},
|
|
206
|
+
identityX: {},
|
|
207
|
+
omedaRapidIdentify: async () => ({}),
|
|
208
|
+
});
|
|
209
|
+
} catch (e) { threw = true; }
|
|
210
|
+
expect(threw).to.equal(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
82
213
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the custom field ids the site's registration/login form presents — the
|
|
3
|
+
* `requiredCreateFieldRows` on the IdentityX configuration. This is the answer scope for the
|
|
4
|
+
* login-path rapid identifications: an answer for any other field is stored state (most
|
|
5
|
+
* dangerously idx-compat's channel-mirror booleans), not something the user said on this form.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('@mindful-web/marko-web-identity-x/config')} idxConfig
|
|
8
|
+
* @returns {string[]} deduped, sorted (rapid-identify's payload contract is array-only)
|
|
9
|
+
*/
|
|
10
|
+
module.exports = (idxConfig) => [...new Set(
|
|
11
|
+
(idxConfig.getRequiredCreateFieldRows() || [])
|
|
12
|
+
.flat()
|
|
13
|
+
.filter((def) => def && typeof def.type === 'string' && def.type.startsWith('custom-') && def.id)
|
|
14
|
+
.map((def) => `${def.id}`),
|
|
15
|
+
)].sort();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const appendBehavior = require('./append-behavior');
|
|
2
2
|
const appendDemographic = require('./append-demographic');
|
|
3
3
|
const appendPromoCode = require('./append-promo-code');
|
|
4
|
+
const appendSubscription = require('./append-subscription');
|
|
4
5
|
const behavior = require('./behavior');
|
|
6
|
+
const deploymentType = require('./deployment-type');
|
|
5
7
|
const hookBehavior = require('./hook-behavior');
|
|
6
8
|
const hookDemographic = require('./hook-demographic');
|
|
7
9
|
const hookPromoCode = require('./hook-promo-code');
|
|
@@ -11,7 +13,9 @@ module.exports = {
|
|
|
11
13
|
appendBehavior,
|
|
12
14
|
appendDemographic,
|
|
13
15
|
appendPromoCode,
|
|
16
|
+
appendSubscription,
|
|
14
17
|
behavior,
|
|
18
|
+
deploymentType,
|
|
15
19
|
hookBehavior,
|
|
16
20
|
hookDemographic,
|
|
17
21
|
hookPromoCode,
|