@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 +3 -0
- package/package.json +8 -4
- package/service.js +31 -9
- package/test/mocha.opts +2 -0
- package/test/tenant.spec.js +99 -0
- package/test/utils.spec.js +85 -0
- package/utils.js +49 -8
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.
|
|
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.
|
|
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": "
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
package/test/mocha.opts
ADDED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
};
|