@mindful-web/marko-web-postup 1.105.1 → 1.109.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/index.js CHANGED
@@ -33,6 +33,7 @@ module.exports = (app, idxConfig, params = {}) => {
33
33
  companyname: 'organization',
34
34
  title: 'organizationTitle',
35
35
  }).description('The PostUp fields that should be mapped to IdentityX fields.'),
36
+ tenant: Joi.string().trim().required().description('The IdentityX external-id namespace tenant (`postup.<tenant>.<type>`) used for recipient ids, list and demographic questions. Required: there is no default.'),
36
37
  }), params);
37
38
 
38
39
  app.use((req, res, next) => {
@@ -66,6 +67,8 @@ module.exports = (app, idxConfig, params = {}) => {
66
67
  * @typedef PostUpConfig
67
68
  * @prop {Array} defaultListQuestionIds Array of object ids representing the default subscription questions
68
69
  * @prop {Object} fieldMap An object representing how fields should map from PostUp to IdentityX.
70
+ * @prop {string} tenant The IdentityX external-id namespace tenant for this site's PostUp recipient
71
+ * ids and list/demographic questions (`postup.<tenant>.<type>`). Required.
69
72
  *
70
73
  * @typedef PostUpRequest
71
74
  * @prop {PostUp} postUp
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-postup",
3
- "version": "1.105.1",
3
+ "version": "1.109.0",
4
4
  "author": "Josh Worden <josh@parameter1.com>",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-postup",
6
6
  "license": "MIT",
7
7
  "scripts": {
8
8
  "lint": "eslint --ext .js --ext .vue --max-warnings 5 --config ../../.eslintrc.js --ignore-path ../../.eslintignore ./",
9
9
  "lint:fix": "yarn lint --fix",
10
- "test": "yarn lint"
10
+ "test": "yarn lint && ../../node_modules/.bin/mocha --reporter spec"
11
11
  },
12
12
  "dependencies": {
13
13
  "@mindful-web/env": "^1.0.0",
14
- "@mindful-web/marko-web-identity-x": "^1.105.1",
14
+ "@mindful-web/marko-web-identity-x": "^1.109.0",
15
15
  "@mindful-web/object-path": "^1.92.0",
16
16
  "@mindful-web/utils": "^1.92.0",
17
17
  "@parameter1/joi": "^1.2.10",
@@ -21,6 +21,10 @@
21
21
  "newrelic": "^9.10.2",
22
22
  "node-fetch": "^2.6.1"
23
23
  },
24
+ "devDependencies": {
25
+ "chai": "^4.3.7",
26
+ "mocha": "^6.2.3"
27
+ },
24
28
  "engines": {
25
29
  "node": ">=14.15"
26
30
  },
@@ -32,5 +36,5 @@
32
36
  "publishConfig": {
33
37
  "access": "public"
34
38
  },
35
- "gitHead": "e6054405dc7918b784fd8cb8b8b0042dd251f248"
39
+ "gitHead": "fc1f82dda4017caab19f5019367c5e2284ed01f9"
36
40
  }
package/service.js CHANGED
@@ -2,7 +2,7 @@ const debug = require('debug')('postup:service');
2
2
  const gql = require('graphql-tag');
3
3
  const { get, getAsArray } = require('@mindful-web/object-path');
4
4
  const { POSTUP_API_CREDENTIALS } = require('./env');
5
- const { filterByExternalId, filterFieldsByExternalId } = require('./utils');
5
+ const { assertTenant, filterByExternalId, filterFieldsByExternalId } = require('./utils');
6
6
  const PostUpApiClient = require('./api-client');
7
7
 
8
8
  const SET_DATA = gql`
@@ -27,12 +27,14 @@ class PostUp {
27
27
  /**
28
28
  * @param {string} defaultListQuestionIds The array of default lists for auto-signups
29
29
  * @param {object} fieldMap A mapping of postup>identityx field keys
30
+ * @param {string} tenant The IdentityX external-id namespace tenant (required)
30
31
  */
31
32
  constructor({
32
33
  defaultListQuestionIds,
33
34
  fieldMap = {},
34
- tenant = 'transpire',
35
+ tenant,
35
36
  } = {}) {
37
+ assertTenant(tenant);
36
38
  this.defaultListQuestionIds = defaultListQuestionIds;
37
39
  this.fieldMap = Object.entries(fieldMap).reduce((map, [key, value]) => {
38
40
  map.set(key, value);
@@ -85,7 +87,11 @@ class PostUp {
85
87
  });
86
88
 
87
89
  // Update custom selects
88
- const questions = filterFieldsByExternalId(getAsArray(user, 'customSelectFieldAnswers'), 'demographic');
90
+ const questions = filterFieldsByExternalId(
91
+ getAsArray(user, 'customSelectFieldAnswers'),
92
+ 'demographic',
93
+ this.tenant,
94
+ );
89
95
  const customSelectFieldAnswers = questions.reduce((arr, ans) => {
90
96
  // Don't overwrite previously set answers
91
97
  if (!ans.hasAnswered) {
@@ -116,7 +122,11 @@ class PostUp {
116
122
 
117
123
  const subs = await this.client.listSubscriptionsForRecipient(recipientId);
118
124
 
119
- const subscriptions = filterFieldsByExternalId(getAsArray(user, 'customBooleanFieldAnswers'), 'list');
125
+ const subscriptions = filterFieldsByExternalId(
126
+ getAsArray(user, 'customBooleanFieldAnswers'),
127
+ 'list',
128
+ this.tenant,
129
+ );
120
130
  const customBooleanFieldAnswers = subscriptions.reduce((arr, ans) => {
121
131
  const listId = get(ans, 'field.externalId.identifier.value');
122
132
  const match = subs.find((sub) => sub.listId === parseInt(listId, 10));
@@ -152,7 +162,11 @@ class PostUp {
152
162
  }), {});
153
163
 
154
164
  // External ID tagged questions
155
- const questions = filterFieldsByExternalId(getAsArray(user, 'customSelectFieldAnswers'), 'demographic');
165
+ const questions = filterFieldsByExternalId(
166
+ getAsArray(user, 'customSelectFieldAnswers'),
167
+ 'demographic',
168
+ this.tenant,
169
+ );
156
170
  questions.forEach((ans) => {
157
171
  if (ans.hasAnswered) {
158
172
  const key = get(ans, 'field.externalId.identifier.value');
@@ -164,7 +178,11 @@ class PostUp {
164
178
  // Update Postup with the user data and demographics
165
179
  await this.updateRecipient(user, demographics);
166
180
 
167
- const answers = filterFieldsByExternalId(getAsArray(user, 'customBooleanFieldAnswers'), 'list');
181
+ const answers = filterFieldsByExternalId(
182
+ getAsArray(user, 'customBooleanFieldAnswers'),
183
+ 'list',
184
+ this.tenant,
185
+ );
168
186
  const subscriptions = answers.reduce((map, answer) => {
169
187
  const id = get(answer, 'field.externalId.identifier.value');
170
188
  map.set(`${id}`, answer.value);
@@ -184,7 +202,7 @@ class PostUp {
184
202
  */
185
203
  async ensureUser(user, idx) {
186
204
  // 1. Check IdentityX user externalIds for postup recipient id, bail if found
187
- const found = filterByExternalId(user.externalIds, 'recipient');
205
+ const found = filterByExternalId(user.externalIds, 'recipient', this.tenant);
188
206
  if (found && found.length) return user;
189
207
 
190
208
  // 2. Attempt to look up by email address. If found, push externalId to IdX/PostUp users
@@ -222,7 +240,11 @@ class PostUp {
222
240
  * @param {import('./hooks').IdentityXUser} user The IdentityX user object
223
241
  */
224
242
  async defaultListOptIns(idx, user) {
225
- const subscriptions = filterFieldsByExternalId(getAsArray(user, 'customBooleanFieldAnswers'), 'list');
243
+ const subscriptions = filterFieldsByExternalId(
244
+ getAsArray(user, 'customBooleanFieldAnswers'),
245
+ 'list',
246
+ this.tenant,
247
+ );
226
248
 
227
249
  const recipientId = await this.getRecipientId(user);
228
250
 
@@ -268,7 +290,7 @@ class PostUp {
268
290
  */
269
291
  async getRecipientId(user) {
270
292
  const { email } = user;
271
- const extIds = filterByExternalId(user.externalIds, 'recipient');
293
+ const extIds = filterByExternalId(user.externalIds, 'recipient', this.tenant);
272
294
  const extId = get(extIds, '0.identifier.value');
273
295
  if (extId) return extId;
274
296
 
@@ -0,0 +1,2 @@
1
+ --recursive
2
+ --timeout 2000
@@ -0,0 +1,99 @@
1
+ const { describe, it, before } = require('mocha');
2
+ const { expect } = require('chai');
3
+
4
+ // The service reads its API credentials from the environment when it is required.
5
+ process.env.POSTUP_API_CREDENTIALS = process.env.POSTUP_API_CREDENTIALS || 'test';
6
+
7
+ const PostUp = require('../service');
8
+ const install = require('../index');
9
+
10
+ const defaultListQuestionIds = ['5f0c8c1e2a1b3c4d5e6f7a8b'];
11
+
12
+ /**
13
+ * Runs the package's request middleware once and returns the service it installed.
14
+ */
15
+ const serviceFor = (params) => {
16
+ let middleware;
17
+ const app = { locals: {}, use: (fn) => { middleware = fn; } };
18
+ const idxConfig = { addHook: () => {} };
19
+ install(app, idxConfig, params);
20
+ const req = { cookies: {} };
21
+ middleware(req, { locals: {} }, () => {});
22
+ return req.PostUp;
23
+ };
24
+
25
+ /**
26
+ * A fake IdentityX service that records the external ids written to it.
27
+ */
28
+ const fakeIdx = () => {
29
+ const added = [];
30
+ return { added, addExternalUserId: async (input) => { added.push(input); } };
31
+ };
32
+
33
+ const user = (tenant) => ({
34
+ id: 'idx-user',
35
+ email: 'user@example.com',
36
+ externalIds: [{
37
+ identifier: { value: '123' },
38
+ namespace: { provider: 'postup', tenant, type: 'recipient' },
39
+ }],
40
+ });
41
+
42
+ describe('tenant', () => {
43
+ describe('index', () => {
44
+ it('rejects a config without a tenant', () => {
45
+ expect(() => serviceFor({ defaultListQuestionIds })).to.throw(/"tenant" is required/);
46
+ });
47
+
48
+ it('rejects an empty tenant', () => {
49
+ expect(() => serviceFor({ defaultListQuestionIds, tenant: ' ' })).to.throw(/"tenant" is not allowed to be empty/);
50
+ });
51
+
52
+ it('passes a configured tenant to the service', () => {
53
+ expect(serviceFor({ defaultListQuestionIds, tenant: 'tenant-a' }).tenant).to.equal('tenant-a');
54
+ });
55
+
56
+ it('passes a second tenant to its own service', () => {
57
+ expect(serviceFor({ defaultListQuestionIds, tenant: 'tenant-b' }).tenant).to.equal('tenant-b');
58
+ });
59
+ });
60
+
61
+ describe('service', () => {
62
+ let service;
63
+ before(() => {
64
+ service = new PostUp({ defaultListQuestionIds, tenant: 'tenant-a' });
65
+ service.client = { updateRecipientExternalId: async () => {} };
66
+ });
67
+
68
+ it('throws when constructed without a tenant', () => {
69
+ expect(() => new PostUp({ defaultListQuestionIds })).to.throw(/tenant.*is required/);
70
+ expect(() => new PostUp()).to.throw(/tenant.*is required/);
71
+ });
72
+
73
+ it('writes the recipient external id in the configured tenant namespace', async () => {
74
+ const idx = fakeIdx();
75
+ await service.setExternalIds(idx, 'idx-user', 456);
76
+ expect(idx.added).to.deep.equal([{
77
+ userId: 'idx-user',
78
+ identifier: { value: '456' },
79
+ namespace: { provider: 'postup', tenant: 'tenant-a', type: 'recipient' },
80
+ }]);
81
+ });
82
+
83
+ it('reads the recipient id from the configured tenant namespace', async () => {
84
+ expect(await service.getRecipientId(user('tenant-a'))).to.equal('123');
85
+ });
86
+
87
+ it('ignores recipient ids from another tenant', async () => {
88
+ service.getUserByEmail = async () => [];
89
+ let error;
90
+ try {
91
+ await service.getRecipientId(user('tenant-b'));
92
+ } catch (e) {
93
+ error = e;
94
+ }
95
+ expect(error).to.be.an('error');
96
+ delete service.getUserByEmail;
97
+ });
98
+ });
99
+ });
@@ -0,0 +1,85 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const { assertTenant, filterByExternalId, filterFieldsByExternalId } = require('../utils');
4
+
5
+ const externalId = (tenant, type, value, provider = 'postup') => ({
6
+ identifier: { value },
7
+ namespace: { provider, tenant, type },
8
+ });
9
+ const answer = (tenant, type, value, provider = 'postup') => ({
10
+ field: { id: `${tenant}-${type}-${value}`, externalId: externalId(tenant, type, value, provider) },
11
+ });
12
+
13
+ const externalIds = [
14
+ externalId('tenant-a', 'recipient', '1'),
15
+ externalId('tenant-b', 'recipient', '2'),
16
+ externalId('tenant-b', 'list', '3'),
17
+ externalId('tenant-b', 'recipient', '4', 'omeda'),
18
+ ];
19
+ const answers = [
20
+ answer('tenant-a', 'list', '10'),
21
+ answer('tenant-a', 'demographic', 'industry'),
22
+ answer('tenant-b', 'list', '20'),
23
+ answer('tenant-b', 'demographic', 'jobtitle'),
24
+ answer('tenant-b', 'list', '30', 'omeda'),
25
+ ];
26
+ const values = (arr, path) => arr.map((v) => path.split('.').reduce((o, k) => o[k], v));
27
+
28
+ describe('utils', () => {
29
+ describe('assertTenant', () => {
30
+ [undefined, null, '', ' ', 1].forEach((tenant) => {
31
+ it(`throws for ${JSON.stringify(tenant)}`, () => {
32
+ expect(() => assertTenant(tenant)).to.throw(/tenant.*is required/);
33
+ });
34
+ });
35
+
36
+ it('accepts a non-empty string', () => {
37
+ expect(() => assertTenant('tenant-a')).to.not.throw();
38
+ });
39
+ });
40
+
41
+ describe('filterByExternalId', () => {
42
+ it('returns only postup ids of the given type for one tenant', () => {
43
+ const found = filterByExternalId(externalIds, 'recipient', 'tenant-a');
44
+ expect(values(found, 'identifier.value')).to.deep.equal(['1']);
45
+ });
46
+
47
+ it('returns only postup ids of the given type for a second tenant', () => {
48
+ const found = filterByExternalId(externalIds, 'recipient', 'tenant-b');
49
+ expect(values(found, 'identifier.value')).to.deep.equal(['2']);
50
+ });
51
+
52
+ it('returns nothing for a tenant with no ids', () => {
53
+ expect(filterByExternalId(externalIds, 'recipient', 'other')).to.deep.equal([]);
54
+ });
55
+
56
+ it('throws without a tenant', () => {
57
+ expect(() => filterByExternalId(externalIds, 'recipient')).to.throw(/tenant.*is required/);
58
+ });
59
+ });
60
+
61
+ describe('filterFieldsByExternalId', () => {
62
+ it('returns only postup fields of the given type for one tenant', () => {
63
+ expect(values(filterFieldsByExternalId(answers, 'list', 'tenant-a'), 'field.id'))
64
+ .to.deep.equal(['tenant-a-list-10']);
65
+ expect(values(filterFieldsByExternalId(answers, 'demographic', 'tenant-a'), 'field.id'))
66
+ .to.deep.equal(['tenant-a-demographic-industry']);
67
+ });
68
+
69
+ it('returns only postup fields of the given type for a second tenant', () => {
70
+ expect(values(filterFieldsByExternalId(answers, 'list', 'tenant-b'), 'field.id'))
71
+ .to.deep.equal(['tenant-b-list-20']);
72
+ expect(values(filterFieldsByExternalId(answers, 'demographic', 'tenant-b'), 'field.id'))
73
+ .to.deep.equal(['tenant-b-demographic-jobtitle']);
74
+ });
75
+
76
+ it('ignores answers without an external id', () => {
77
+ expect(filterFieldsByExternalId([{ field: { id: 'x' } }], 'list', 'tenant-a'))
78
+ .to.deep.equal([]);
79
+ });
80
+
81
+ it('throws without a tenant', () => {
82
+ expect(() => filterFieldsByExternalId(answers, 'list')).to.throw(/tenant.*is required/);
83
+ });
84
+ });
85
+ });
package/utils.js CHANGED
@@ -1,12 +1,53 @@
1
1
  const { getAsObject } = require('@mindful-web/object-path');
2
2
 
3
+ /**
4
+ * Throws unless the tenant is a non-empty string. There is no default tenant: a missing tenant
5
+ * would otherwise silently match no external ids (and so write recipient ids and answers into
6
+ * an `undefined` namespace).
7
+ *
8
+ * @param {string} tenant
9
+ */
10
+ const assertTenant = (tenant) => {
11
+ if (typeof tenant !== 'string' || !tenant.trim()) {
12
+ throw new Error('A PostUp `tenant` (the IdentityX external-id namespace tenant) is required.');
13
+ }
14
+ };
15
+
16
+ const matches = (ns, type, tenant) => ns.provider === 'postup'
17
+ && ns.type === type
18
+ && ns.tenant === tenant;
19
+
3
20
  module.exports = {
4
- filterByExternalId: (arr, type) => arr.filter((v) => {
5
- const ns = getAsObject(v, 'namespace');
6
- return ns.provider === 'postup' && ns.type === type && ns.tenant === 'transpire';
7
- }),
8
- filterFieldsByExternalId: (arr, type) => arr.filter((v) => {
9
- const ns = getAsObject(v, 'field.externalId.namespace');
10
- return ns.provider === 'postup' && ns.type === type && ns.tenant === 'transpire';
11
- }),
21
+ assertTenant,
22
+
23
+ /**
24
+ * Returns the external ids in the PostUp namespace for the given type and tenant.
25
+ *
26
+ * @param {Array} arr IdentityX external ids (`{ namespace, identifier }`)
27
+ * @param {string} type The namespace type, e.g. `recipient`
28
+ * @param {string} tenant The namespace tenant (required)
29
+ */
30
+ filterByExternalId: (arr, type, tenant) => {
31
+ assertTenant(tenant);
32
+ return arr.filter((v) => {
33
+ const ns = getAsObject(v, 'namespace');
34
+ return matches(ns, type, tenant);
35
+ });
36
+ },
37
+
38
+ /**
39
+ * Returns the custom field answers whose field external id is in the PostUp namespace for the
40
+ * given type and tenant.
41
+ *
42
+ * @param {Array} arr IdentityX custom field answers (`{ field: { externalId: { namespace } } }`)
43
+ * @param {string} type The namespace type, e.g. `demographic` or `list`
44
+ * @param {string} tenant The namespace tenant (required)
45
+ */
46
+ filterFieldsByExternalId: (arr, type, tenant) => {
47
+ assertTenant(tenant);
48
+ return arr.filter((v) => {
49
+ const ns = getAsObject(v, 'field.externalId.namespace');
50
+ return matches(ns, type, tenant);
51
+ });
52
+ },
12
53
  };