@mindful-web/marko-web-identity-x 1.83.1 → 1.85.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.
Files changed (35) hide show
  1. package/CLAUDE.md +281 -0
  2. package/api/mutations/progressive-profile-update.js +29 -0
  3. package/browser/index.js +13 -0
  4. package/browser/progressive-profile.vue +388 -0
  5. package/components/access.marko.js +2 -2
  6. package/components/comment-stream.marko.js +2 -2
  7. package/components/context.marko.js +2 -2
  8. package/components/form-access.marko.js +2 -2
  9. package/components/form-authenticate.marko.js +2 -2
  10. package/components/form-change-email.marko.js +2 -2
  11. package/components/form-login.marko.js +2 -2
  12. package/components/form-logout.marko.js +2 -2
  13. package/components/form-profile.marko.js +2 -2
  14. package/components/form-register.marko.js +2 -2
  15. package/components/google-init.marko.js +2 -2
  16. package/components/google-sign-in-button.marko.js +2 -2
  17. package/components/identify.marko.js +2 -2
  18. package/components/marko.json +16 -0
  19. package/components/non-auth-identify.marko +6 -1
  20. package/components/non-auth-identify.marko.js +3 -3
  21. package/components/progressive-profile-form.marko +106 -0
  22. package/components/progressive-profile-form.marko.js +138 -0
  23. package/components/subscribe.marko.js +2 -2
  24. package/config.js +98 -0
  25. package/package.json +3 -2
  26. package/routes/google.js +5 -0
  27. package/routes/index.js +3 -0
  28. package/routes/progressive-profile.js +296 -0
  29. package/service.js +31 -0
  30. package/utils/add-custom-attributes.js +19 -46
  31. package/utils/build-progressive-profile-payload.js +117 -0
  32. package/utils/get-missing-progressive-fields.js +114 -0
  33. package/utils/get-progressive-form-fields.js +67 -0
  34. package/utils/progressive-profile-cookies.js +120 -0
  35. package/utils/progressive-profile-telemetry.js +148 -0
@@ -0,0 +1,296 @@
1
+ const { verifyToken } = require('@mindful-web/marko-web-recaptcha');
2
+ const { asyncRoute } = require('@mindful-web/utils');
3
+ const { getAsArray } = require('@mindful-web/object-path');
4
+ const PROGRESSIVE_PROFILE_UPDATE = require('../api/mutations/progressive-profile-update');
5
+ const buildPayload = require('../utils/build-progressive-profile-payload');
6
+ const callHooksFor = require('../utils/call-hooks-for');
7
+ const cookies = require('../utils/progressive-profile-cookies');
8
+ const { EVENT_TYPES, createRecorder } = require('../utils/progressive-profile-telemetry');
9
+
10
+ const { buildContentSource } = buildPayload;
11
+
12
+ const ACTION_SOURCE = 'progressiveProfile';
13
+
14
+ /**
15
+ * The reCAPTCHA v3 action for this form. Declared client-side and checked server-side, so a token
16
+ * minted for some other form on the site cannot be replayed here.
17
+ */
18
+ const RECAPTCHA_ACTION = 'progressiveProfileSubmit';
19
+
20
+ /**
21
+ * Score floor. The package default is 0.5, which is what every other form on the fleet uses.
22
+ * Overridable per site via `progressiveProfile.recaptchaMinimumScore` on the IdentityX config.
23
+ */
24
+ const DEFAULT_MINIMUM_SCORE = 0.5;
25
+
26
+ const DEFAULTS = {
27
+ suppressAfterDismissDays: 0,
28
+ unverifiedSuppressDays: cookies.DEFAULT_UNVERIFIED_SUPPRESS_DAYS,
29
+ };
30
+
31
+ const optionFor = (config, key) => config.get(`progressiveProfile.${key}`, DEFAULTS[key]);
32
+
33
+ /**
34
+ * `POST /__idx/progressive-profile`
35
+ *
36
+ * A thin proxy over the single `progressiveProfileUpdate` mutation. It holds **no write logic of
37
+ * its own**: the re-diff that drops already-filled fields, the attribute writes, the
38
+ * `action-history` attribution and the `PROGRESSIVE_PROFILE_SUBMIT` submission event all happen
39
+ * inside that mutation's transaction.
40
+ *
41
+ * What this route *does* own is trust: which member is being written to (never `body.userId`),
42
+ * which fields may be written (server config, never the body), and reCAPTCHA.
43
+ *
44
+ * ## Bot protection — reCAPTCHA, and what a failure actually does
45
+ *
46
+ * The threat is concrete. `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
47
+ * `__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a
48
+ * newsletter security scanner following an emailed link browses *as* an identified human and could
49
+ * write to a real record. Cloudflare on these sites segregates rather than drops, so there is no
50
+ * upstream to lean on.
51
+ *
52
+ * Verification is the fleet standard — reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`, the
53
+ * same package and score floor behind newsletter signup, contact-us, inquiries and the RigDig
54
+ * checkout. What differs is the **response to a failure**.
55
+ *
56
+ * **A failed check never rejects the visitor. It silently drops the write.** The submission
57
+ * "succeeds", the content unlocks, and the answers go nowhere. Rejecting instead would be strictly
58
+ * worse: this gate stands between a reader and an article, so a false positive locks a real person
59
+ * out of content they can otherwise reach — a bigger loss than the bot write it prevented. A bot,
60
+ * meanwhile, gains only an article it could largely have read anonymously, and gains no write at
61
+ * all, which is the only thing actually at stake.
62
+ *
63
+ * Two consequences that are easy to miss:
64
+ *
65
+ * - **A suppression cookie is mandatory, not a nicety.** The answers were discarded, so the
66
+ * member's record is unchanged and the diff would gate them again on the next article, forever.
67
+ * `setUnverifiedSuppressed` stops that and hands them back to the content meter.
68
+ * - **A false positive is invisible to everyone.** The visitor is never told, and we would never
69
+ * know either — which is why the score is recorded on *every* submit, passing or not. That
70
+ * telemetry is what makes this shape safe to ship; see `CLAUDE.md` for the decision rule it
71
+ * feeds.
72
+ *
73
+ * **One rule for every visitor.** A failed check drops the write whether or not a session is
74
+ * present. An earlier revision exempted session holders on the grounds that `/user/profile` has no
75
+ * captcha either, so a session is already sufficient authorization for a profile write elsewhere in
76
+ * this package — but that is a user-initiated edit on a page someone navigated to, while this is an
77
+ * interstitial pushed at a link-follower, which is exactly what an email scanner hits. Scanners do
78
+ * follow magic links, so a session is not proof of a human.
79
+ *
80
+ * The exemption also bought nothing the uniform rule does not: the score is recorded on every
81
+ * submit regardless, so authenticated submits still serve as the known-human control group that
82
+ * says whether the threshold itself is wrong. Only the write differs, and passive accept means the
83
+ * visitor keeps the content either way.
84
+ *
85
+ * The hard damage bound is not here at all: the mutation re-diffs inside its transaction and can
86
+ * only ever fill a field that was already empty, so nothing that gets past reCAPTCHA can destroy
87
+ * data. Every write is also recorded in `action-history` with its `input`/`result`, so a bad one is
88
+ * findable and reversible from one collection.
89
+ */
90
+ const submit = asyncRoute(async (req, res) => {
91
+ /** @type {import('../middleware').IdentityXRequest} */
92
+ const { identityX, body = {} } = req;
93
+ const { config } = identityX;
94
+ const onError = config.get('onHookError') || (() => {});
95
+
96
+ // Telemetry: exactly one event per submit, typed by what happened -- `ProgressiveProfileSubmit`,
97
+ // `Discarded`, `Rejected` or `Error`. Each terminal branch below records its own, which is what
98
+ // makes the outcomes separately countable.
99
+ //
100
+ // The arithmetic is guaranteed by the recorder, not by whoever edits this next:
101
+ // `record` is a no-op once anything has fired (so no path can double count), and the `finally`
102
+ // at the bottom is a backstop for a path that recorded nothing. If you add a branch and forget
103
+ // the call, it does not go missing -- it surfaces as `ProgressiveProfileError` with
104
+ // `reason: 'unrecorded'`.
105
+ //
106
+ // `telemetry.set(...)` accumulates the attributes shared by whichever type ends up firing, so a
107
+ // call site only names what is specific to it.
108
+ const telemetry = createRecorder(req);
109
+ telemetry.set({ contentId: body.contentId == null ? null : `${body.contentId}` });
110
+
111
+ try {
112
+ // --- Verify, but do not reject. -------------------------------------------------------------
113
+ const {
114
+ success, score, action, errorCodes,
115
+ } = await verifyToken({
116
+ token: body.recaptchaToken,
117
+ secretKey: config.get(
118
+ 'progressiveProfile.recaptchaSecretKey',
119
+ process.env.RECAPTCHA_V3_SECRET_KEY,
120
+ ),
121
+ });
122
+ const minimumScore = config.get('progressiveProfile.recaptchaMinimumScore', DEFAULT_MINIMUM_SCORE);
123
+
124
+ // Each outcome is recorded separately. `no-token` in volume means blocked scripts, not bots;
125
+ // `wrong-action` means a token minted for another form on the site; `low-score` is the only one
126
+ // whose rate is a judgement call, and the only one the score can explain.
127
+ const outcome = (() => {
128
+ if (!body.recaptchaToken) return 'no-token';
129
+ if (!success) return errorCodes.includes('timeout-or-duplicate') ? 'stale-token' : 'bad-token';
130
+ if (action !== RECAPTCHA_ACTION) return 'wrong-action';
131
+ // `score != null` mirrors `validateToken`: a success carrying no score is not a low score.
132
+ if (score != null && score < minimumScore) return 'low-score';
133
+ return 'passed';
134
+ })();
135
+ // Recorded on every submit, including passes -- a rejection rate is unactionable without the
136
+ // distribution behind it, and the passing scores are the baseline it has to be read against.
137
+ telemetry.set({ recaptcha: outcome, ...(score != null && { recaptchaScore: score }) });
138
+
139
+ // --- Target resolution. `body.userId` is never trusted. -------------------------------------
140
+ const { user: authUser } = await identityX.loadActiveContext();
141
+ const targetId = authUser ? authUser.id : identityX.getIdentity(res);
142
+ if (!targetId) {
143
+ telemetry.record(EVENT_TYPES.REJECTED, { reason: 'no-identity' });
144
+ res.status(401).json({ ok: false, message: 'No identity present.' });
145
+ return;
146
+ }
147
+ if (!authUser && body.userId && `${body.userId}` !== `${targetId}`) {
148
+ telemetry.record(EVENT_TYPES.REJECTED, { reason: 'identity-mismatch' });
149
+ res.status(403).json({ ok: false, message: 'The supplied user does not match the current identity.' });
150
+ return;
151
+ }
152
+
153
+ const isOrgAuth = !authUser;
154
+ telemetry.set({ userId: `${targetId}`, auth: isOrgAuth ? 'org' : 'user' });
155
+
156
+ // --- Passive accept: unlock the content, drop the write. ------------------------------------
157
+ // Checked before the payload is built, so a body we were never going to write is a quiet 200
158
+ // rather than a 400 explaining what a better-formed request would have looked like.
159
+ if (outcome !== 'passed') {
160
+ cookies.setUnverifiedSuppressed(res, optionFor(config, 'unverifiedSuppressDays'));
161
+ cookies.clearAttempts(res);
162
+ telemetry.record(EVENT_TYPES.DISCARDED);
163
+ // `changed: false` is what the GA4 conversion already filters on, so a discarded submit is
164
+ // excluded from completions without any GTM change. `accepted` makes it separately countable.
165
+ res.json({
166
+ ok: true, changed: false, accepted: 'unverified', user: null,
167
+ });
168
+ return;
169
+ }
170
+
171
+ // --- What may be written comes from server config, never the request. -----------------------
172
+ const definitions = config.getProgressiveProfileFieldRows()
173
+ .reduce((arr, row) => [...arr, ...getAsArray({ row }, 'row')], []);
174
+ const { selectAnswers, booleanAnswers, textAnswers } = buildPayload({ body, definitions });
175
+ const fieldCount = selectAnswers.length + booleanAnswers.length + textAnswers.length;
176
+ if (!fieldCount) {
177
+ // The mutation throws a `UserInputError` for this anyway; catching it here keeps a client bug
178
+ // out of the audience API's error log.
179
+ telemetry.record(EVENT_TYPES.REJECTED, { reason: 'no-answers', fieldCount });
180
+ res.status(400).json({ ok: false, message: 'At least one answer is required.' });
181
+ return;
182
+ }
183
+
184
+ const { data } = await identityX.client.mutate({
185
+ mutation: PROGRESSIVE_PROFILE_UPDATE,
186
+ variables: {
187
+ input: {
188
+ // Ignored on the AppUser path; required on the org path. Sent from the *resolved* target,
189
+ // not the body.
190
+ ...(isOrgAuth && { userId: targetId }),
191
+ ...(selectAnswers.length && { selectAnswers }),
192
+ ...(booleanAnswers.length && { booleanAnswers }),
193
+ ...(textAnswers.length && { textAnswers }),
194
+ // `contentId` from the body, as `routes/access.js` already does. The mutation
195
+ // validates it is exactly 8 digits and belongs to the request's own tenant before
196
+ // deriving `postId`, so the worst a wrong value can do is misattribute one submission
197
+ // event -- it cannot reach another tenant's content or change who is written to.
198
+ source: buildContentSource({
199
+ config,
200
+ contentId: body.contentId,
201
+ actionSource: ACTION_SOURCE,
202
+ }),
203
+ },
204
+ },
205
+ // An authenticated visitor writes as themselves; an identified one is written by the org.
206
+ ...(isOrgAuth && { context: { apiToken: identityX.getOrgUserApiToken() } }),
207
+ });
208
+
209
+ const { ok, changed, appUser } = data.progressiveProfileUpdate;
210
+
211
+ // Release the escape valve. Cleared on any success, not only on `changed: true` -- a no-op
212
+ // submit means the fields are already filled, so the next render will not gate anyway and a
213
+ // stale counter would only shorten someone else's runway.
214
+ cookies.clearAttempts(res);
215
+
216
+ // --- Downstream profile-update hooks. --------------------------------------------------------
217
+ // `onUserProfileUpdate` is provider-agnostic. Omeda rapid-identify is the most visible consumer
218
+ // but it is one of five: `marko-web-omeda-identity-x`, `marko-web-identity-x-mailchimp`,
219
+ // `marko-web-postup`, plus site-level packages for Braze and ActiveCampaign. Which of them runs
220
+ // is purely a function of what a site installed.
221
+ //
222
+ // Skipped when nothing changed (nothing to sync) and never fatal: the audience write has
223
+ // already committed, so failing the request here would report a loss that did not happen.
224
+ //
225
+ // Deliberately NOT `onLoginLinkSent` / `onAuthenticationSuccess`. Those drive auto-signup and
226
+ // the GA4 `subscribe` conversion; this is not a registration, and firing them reports phantom
227
+ // subscriptions.
228
+ //
229
+ // `dispatched` means the queue was fired -- NOT that any hook succeeded, and do not "correct"
230
+ // it to `ok`. Every registrant uses `shouldAwait: false`, so `callHooksFor` returns before the
231
+ // hooks have done anything, and `utils/call-hooks-for.js` wraps each promise in
232
+ // `.catch(onHookError)` so it essentially never rejects. An async failure -- the realistic one,
233
+ // e.g. Omeda timing out seconds later -- is reported there and can never reach this field.
234
+ // `error` is therefore a *synchronous* throw from dispatch only: guarded because the write has
235
+ // already committed and an unhandled throw would 500 a request whose data actually landed, and
236
+ // reported locally because `call-hooks-for` only catches returned promises, so this is not a
237
+ // double report.
238
+ let profileUpdateHooks = 'skipped';
239
+ if (changed && appUser) {
240
+ try {
241
+ await callHooksFor(identityX, 'onUserProfileUpdate', {
242
+ req,
243
+ user: appUser,
244
+ additionalEventData: { actionSource: ACTION_SOURCE },
245
+ actionSource: ACTION_SOURCE,
246
+ });
247
+ profileUpdateHooks = 'dispatched';
248
+ } catch (e) {
249
+ profileUpdateHooks = 'error';
250
+ onError(e, { idxProgressiveProfileStep: 'onUserProfileUpdate', idxUserId: targetId });
251
+ }
252
+ }
253
+
254
+ telemetry.record(EVENT_TYPES.SUBMIT, {
255
+ fieldCount,
256
+ changed: Boolean(changed),
257
+ profileUpdateHooks,
258
+ });
259
+
260
+ // No `actionId` and no `entity`: the submit is recorded server-side and the browser has no
261
+ // tracking left to do on it. `changed` is server-truth and is what the GA4 conversion
262
+ // filters on. `accepted` here is the client contract the dataLayer event reads -- unrelated to
263
+ // the New Relic event type above, which carries the same fact for reporting.
264
+ res.json({
265
+ ok: Boolean(ok), changed: Boolean(changed), accepted: 'written', user: appUser || null,
266
+ });
267
+ } catch (e) {
268
+ // Record before rethrowing, so a failure is a row rather than a gap. The error handler still
269
+ // gets the throw; this only observes it. Message only -- submitted answers never go to New
270
+ // Relic, and the mutation's errors are generic by design.
271
+ telemetry.record(EVENT_TYPES.ERROR, { error: `${e.name}: ${e.message}` });
272
+ throw e;
273
+ } finally {
274
+ // Backstop, and a no-op on every path above because they all recorded. It fires only when a
275
+ // branch returned without one -- and it says so, rather than guessing a type, so the gap is
276
+ // visible in New Relic instead of silently skewing the mix.
277
+ telemetry.record(EVENT_TYPES.ERROR, { reason: 'unrecorded' });
278
+ }
279
+ });
280
+
281
+ /**
282
+ * `POST /__idx/progressive-profile/dismiss`
283
+ *
284
+ * Only meaningful when a site opts into `dismissible` + `suppressAfterDismissDays`; with the
285
+ * default hard-gate cadence there is no skip affordance to reach it and the cookie is never set.
286
+ *
287
+ * No reCAPTCHA: this writes nothing but a cookie on the caller's own browser.
288
+ */
289
+ const dismiss = asyncRoute(async (req, res) => {
290
+ /** @type {import('../middleware').IdentityXRequest} */
291
+ const { identityX } = req;
292
+ const dismissed = cookies.setDismissed(res, optionFor(identityX.config, 'suppressAfterDismissDays'));
293
+ res.json({ ok: true, dismissed });
294
+ });
295
+
296
+ module.exports = { RECAPTCHA_ACTION, dismiss, submit };
package/service.js CHANGED
@@ -354,6 +354,37 @@ class IdentityX {
354
354
  return data.appUserById;
355
355
  }
356
356
 
357
+ /**
358
+ * `findUserById`, memoized for the life of the request.
359
+ *
360
+ * A gated page load asks for the same record up to three times — the progressive-profile
361
+ * middleware, `getIdentityData()`, and `non-auth-identify.marko` — for one identity that cannot
362
+ * change mid-request. This collapses them to a single round trip.
363
+ *
364
+ * **Deliberately a separate method rather than caching `findUserById` itself.**
365
+ * `routes/google.js` calls `findUserById` twice on purpose, either side of the
366
+ * `onLoginLinkSent` hook, precisely to observe the `externalIds` that hook writes — memoizing
367
+ * the shared method would hand it stale data and silently drop the Omeda `oly_enc_id` from the
368
+ * response. Only read paths that perform no writes may use this.
369
+ *
370
+ * @param {String} id The user id
371
+ * @returns {Promise<User|null>}
372
+ */
373
+ async findUserByIdMemoized(id) {
374
+ if (!id) return null;
375
+ if (!this.userByIdQueries) this.userByIdQueries = new Map();
376
+ if (!this.userByIdQueries.has(id)) {
377
+ // The *promise* is cached, not its result, so concurrent callers share one in-flight query
378
+ // rather than each starting their own.
379
+ this.userByIdQueries.set(id, this.findUserById(id).catch((e) => {
380
+ // A failed lookup must not be cached as a permanent failure for the rest of the request.
381
+ this.userByIdQueries.delete(id);
382
+ throw e;
383
+ }));
384
+ }
385
+ return this.userByIdQueries.get(id);
386
+ }
387
+
357
388
  /**
358
389
  *
359
390
  * @param {object} params
@@ -1,50 +1,23 @@
1
+ const { newrelicAgent } = require('@mindful-web/utils');
2
+
1
3
  /**
2
- * Default sink for `config.addRequestAttributes` — tags the current New Relic
3
- * transaction, with no configuration and no New Relic dependency.
4
+ * Default sink for `config.addRequestAttributes` — tags the current New Relic transaction.
5
+ *
6
+ * A thin delegation to `@mindful-web/utils`, which owns the agent resolution: it uses the agent
7
+ * **only when the host application has already loaded it** (a `require.cache` check rather than a
8
+ * try/caught require, because requiring `newrelic` cold would *start* an agent on sites that don't
9
+ * use it). Net effect is unchanged — attributes arrive fleet-wide on a dependency bump and the
10
+ * whole thing is inert without New Relic — and it swallows every error, because telemetry must
11
+ * never break a request.
12
+ *
13
+ * This file used to carry its own copy of that resolution logic, line for line. It is gone:
14
+ * identity-x already depends on `@mindful-web/utils` directly *and* transitively
15
+ * (`utils/create-client.js` → `instrumentApolloClient` → `newrelic-agent`), so the duplicate was
16
+ * two copies that could only ever drift apart.
4
17
  *
5
- * The agent is used **only when the application has already loaded it**. Every
6
- * marko-web site requires its `newrelic.js` at boot, so by the time a request
7
- * is served the module is in `require.cache` and `require('newrelic')` returns
8
- * that same initialized singleton. Requiring it unconditionally would instead
9
- * *start* an agent on sites that don't use New Relic, which is why this checks
10
- * the cache rather than just try/catching the require.
18
+ * A site can still pass its own `addRequestAttributes` to override this entirely. For telemetry
19
+ * that should **not** ride on the transaction, see `utils/progressive-profile-telemetry.js`.
11
20
  *
12
- * Net effect: attributes appear fleet-wide on a dependency bump alone, and the
13
- * whole thing is inert on any site without New Relic. A site can still pass its
14
- * own `addRequestAttributes` to override.
21
+ * @param {object} attributes Flat key/values only — New Relic drops nested objects.
15
22
  */
16
-
17
- let resolvedPath;
18
- let lookupFailed = false;
19
- let agent = null;
20
-
21
- const getAgent = () => {
22
- if (agent) return agent;
23
- if (lookupFailed) return null;
24
- if (!resolvedPath) {
25
- try {
26
- resolvedPath = require.resolve('newrelic');
27
- } catch (e) {
28
- // Not installed — nothing to report to.
29
- lookupFailed = true;
30
- return null;
31
- }
32
- }
33
- // `newrelic` is intentionally NOT a dependency of this package: declaring it
34
- // would install it on every monorail site, and requiring an uninitialized
35
- // agent starts one. We only ever pick up an instance the host app already
36
- // created, which is why the cache check above guards this require.
37
- // eslint-disable-next-line global-require, import/no-extraneous-dependencies
38
- if (require.cache[resolvedPath]) agent = require('newrelic');
39
- return agent;
40
- };
41
-
42
- module.exports = (attributes) => {
43
- const nr = getAgent();
44
- if (!nr || typeof nr.addCustomAttributes !== 'function') return;
45
- try {
46
- nr.addCustomAttributes(attributes);
47
- } catch (e) {
48
- // Telemetry must never break a request.
49
- }
50
- };
23
+ module.exports = (attributes) => newrelicAgent.addCustomAttributes(attributes);
@@ -0,0 +1,117 @@
1
+ const { getAsArray } = require('@mindful-web/object-path');
2
+
3
+ /**
4
+ * Locates the submitted answer for a definition.
5
+ *
6
+ * `get-progressive-form-fields.js` stamps `id` with the **field** id (the contract
7
+ * `custom-column.vue` reads), so that is the primary match. `field.id` is accepted as a fallback
8
+ * for an answer that arrived straight off an idx record without passing through that util.
9
+ */
10
+ const findSubmitted = (answers, fieldId) => answers.find((answer) => answer && (
11
+ answer.id === fieldId || (answer.field && answer.field.id === fieldId)
12
+ ));
13
+
14
+ const isBlank = (value) => value == null || `${value}`.trim() === '';
15
+
16
+ /**
17
+ * Marshals a submitted progressive-profile form body into `progressiveProfileUpdate` variables.
18
+ *
19
+ * Three things it deliberately does **not** do:
20
+ *
21
+ * - **Decide what is missing.** The mutation re-diffs every answer against the member's live
22
+ * record inside its own transaction and drops anything already filled, so a stale form cannot
23
+ * overwrite good data and this util does not need to guarantee freshness.
24
+ * - **Trust the request for field definitions.** `definitions` comes from server config
25
+ * (`config.getProgressiveProfileFieldRows()`), never from the body, so a client cannot widen
26
+ * what gets written by posting extra rows.
27
+ * - **Write built-ins.** The shipped mutation is attributes-only; there is no `builtIns` input.
28
+ * `config.js` refuses to boot with a `built-in` column for that reason.
29
+ *
30
+ * @param {object} params
31
+ * @param {object} [params.body] The posted form body (`{ ...user }` from the Vue component).
32
+ * @param {import('..').CustomColumnDefinition[]} [params.definitions] Flattened, server-supplied.
33
+ * @returns {{ selectAnswers: object[], booleanAnswers: object[], textAnswers: object[] }}
34
+ */
35
+ module.exports = ({ body, definitions = [] } = {}) => {
36
+ const selectAnswers = [];
37
+ const booleanAnswers = [];
38
+ const textAnswers = [];
39
+
40
+ const submitted = {
41
+ 'custom-select': getAsArray(body, 'customSelectFieldAnswers'),
42
+ 'custom-boolean': getAsArray(body, 'customBooleanFieldAnswers'),
43
+ 'custom-text': getAsArray(body, 'customTextFieldAnswers'),
44
+ };
45
+
46
+ definitions.forEach((col) => {
47
+ if (!col || !col.id) return;
48
+ const answer = findSubmitted(submitted[col.type] || [], col.id);
49
+ if (!answer) return;
50
+
51
+ switch (col.type) {
52
+ case 'custom-select': {
53
+ const options = getAsArray(answer, 'answers').filter((option) => option && option.id);
54
+ // An empty selection is not an answer. Sending it anyway would post `optionIds: []`,
55
+ // which the underlying writer reads as "unset" — the one way this feature could destroy
56
+ // data despite the mutation's fill-blanks-only rule.
57
+ if (!options.length) return;
58
+ selectAnswers.push({
59
+ fieldId: col.id,
60
+ optionIds: options.map(({ id }) => id),
61
+ writeInValues: options.reduce((arr, { id, writeInValue }) => ([
62
+ ...arr,
63
+ ...(isBlank(writeInValue) ? [] : [{ optionId: id, value: writeInValue }]),
64
+ ]), []),
65
+ });
66
+ break;
67
+ }
68
+ case 'custom-boolean': {
69
+ // `hasAnswered` is the gate, not truthiness: an explicit `false` is an answer.
70
+ //
71
+ // Unreachable today — `config.js` rejects `custom-boolean` at boot because the shared
72
+ // `custom-column.vue` cannot render a boolean's answered state (it binds `answer` but the
73
+ // change handler writes `value`), which makes a *required* boolean an unsatisfiable gate.
74
+ // Kept in step with the diff util and the mutation so re-enabling it is one line once
75
+ // that shared bug is fixed.
76
+ if (!answer.hasAnswered) return;
77
+ const value = answer.value == null ? answer.answer : answer.value;
78
+ booleanAnswers.push({ fieldId: col.id, value: Boolean(value) });
79
+ break;
80
+ }
81
+ case 'custom-text': {
82
+ if (isBlank(answer.value)) return;
83
+ textAnswers.push({ fieldId: col.id, value: `${answer.value}`.trim() });
84
+ break;
85
+ }
86
+ default:
87
+ break;
88
+ }
89
+ });
90
+
91
+ return { selectAnswers, booleanAnswers, textAnswers };
92
+ };
93
+
94
+ /**
95
+ * Assembles the mutation's required `source` object — where the gate was rendered.
96
+ *
97
+ * All four values are validated server-side: `legacyTenantKey` must resolve to a known legacy
98
+ * tenant **and** match the tenant the request resolved to, and `integerId` must be exactly 8
99
+ * digits (true of all 442 content routes across the 18 website repos). A mismatch therefore
100
+ * surfaces as a `UserInputError` rather than silently recording a wrong `postId`.
101
+ *
102
+ * `websiteChannelId` is the idx app id: the two are the same value, confirmed against the tenancy
103
+ * `websites` map and the audience `attributes.websiteChannelIds` data.
104
+ *
105
+ * @param {object} params
106
+ * @param {import('../config')} params.config
107
+ * @param {string|number} params.contentId The base-CMS post integer id.
108
+ * @param {string} [params.actionSource]
109
+ */
110
+ module.exports.buildContentSource = ({ config, contentId, actionSource } = {}) => ({
111
+ // The same env var `marko-web-p1-events/utils/create-namespace.js` reads, and `package.json`'s
112
+ // `website.tenant`.
113
+ legacyTenantKey: process.env.TENANT_KEY,
114
+ integerId: Number.parseInt(contentId, 10),
115
+ websiteChannelId: config.getAppId(),
116
+ ...(actionSource && { actionSource }),
117
+ });
@@ -0,0 +1,114 @@
1
+ const { getAsArray } = require('@mindful-web/object-path');
2
+
3
+ /**
4
+ * The only column types progressive profiling can ask for.
5
+ *
6
+ * `built-in` is deliberately absent. The `progressiveProfileUpdate` mutation is **attributes
7
+ * only** — core/root member fields were cut from it during review — so a built-in column would
8
+ * render in the gate, accept a value, and then be silently unwritable: the user submits, the
9
+ * mutation reports `changed: false`, and the gate re-renders forever. `config.js` refuses to boot
10
+ * with one for that reason; this set is the second half of the same guard.
11
+ */
12
+ const SUPPORTED_TYPES = new Set(['custom-select', 'custom-boolean', 'custom-text']);
13
+
14
+ /**
15
+ * Finds a user's answer for a field definition.
16
+ *
17
+ * Matched on `answer.field.id`, **never** `answer.id`. An idx custom answer carries its own id —
18
+ * the id of the answer document, not of the field it answers — so comparing `answer.id` to a
19
+ * configured field id never matches. That is the live bug in `utils/get-form-custom-fields.js`
20
+ * (and in `config.getGTMUserData`), which is why this feature does not reuse either.
21
+ *
22
+ * @param {object[]} answers
23
+ * @param {string} fieldId
24
+ */
25
+ const findAnswer = (answers, fieldId) => answers
26
+ .find((answer) => answer && answer.field && answer.field.id === fieldId);
27
+
28
+ /**
29
+ * Whether a single configured column is unanswered on the given user.
30
+ *
31
+ * @param {object} params
32
+ * @param {object} params.appUser
33
+ * @param {import('..').CustomColumnDefinition} params.col
34
+ * @returns {boolean}
35
+ */
36
+ const isColumnMissing = ({ appUser, col }) => {
37
+ const { id, type } = col;
38
+ // An unsupported or id-less column can never be written, so it must never gate anyone.
39
+ if (!id || !SUPPORTED_TYPES.has(type)) return false;
40
+
41
+ switch (type) {
42
+ case 'custom-select': {
43
+ const answer = findAnswer(getAsArray(appUser, 'customSelectFieldAnswers'), id);
44
+ if (!answer || !answer.hasAnswered) return true;
45
+ return !getAsArray(answer, 'answers').length;
46
+ }
47
+ case 'custom-text': {
48
+ const answer = findAnswer(getAsArray(appUser, 'customTextFieldAnswers'), id);
49
+ if (!answer || !answer.hasAnswered) return true;
50
+ const { value } = answer;
51
+ return value == null || `${value}`.trim() === '';
52
+ }
53
+ case 'custom-boolean': {
54
+ const answer = findAnswer(getAsArray(appUser, 'customBooleanFieldAnswers'), id);
55
+ // `hasAnswered` is the whole test: a deliberate `false` IS an answer.
56
+ return !answer || !answer.hasAnswered;
57
+ }
58
+ /* istanbul ignore next -- guarded by SUPPORTED_TYPES above */
59
+ default:
60
+ return false;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * Diffs a user's AppUser record against a site's configured progressive-profile fields.
66
+ *
67
+ * Pure — no i/o, no config access. The middleware calls it to decide whether to gate; the Marko
68
+ * tag calls it (via the state the middleware set) to decide what to render.
69
+ *
70
+ * The NA relaxation in `generate-required-field-payload.js` (skip `regionCode`/`postalCode`
71
+ * outside US/CA/MX) has no analogue here: those are built-ins, which this feature cannot ask for.
72
+ *
73
+ * @param {object} params
74
+ * @param {object} [params.appUser] The full idx AppUser record (with custom answers).
75
+ * @param {import('..').CustomColumnDefinition[][]} [params.fieldRows]
76
+ * @returns {{
77
+ * hasMissing: boolean,
78
+ * missingRows: import('..').CustomColumnDefinition[][],
79
+ * missingCustomFieldIds: string[],
80
+ * missingCount: number,
81
+ * }}
82
+ */
83
+ module.exports = ({ appUser, fieldRows = [] } = {}) => {
84
+ // Never gate a user we could not read. An absent record is a lookup failure, not a profile with
85
+ // every field empty, and treating it as the latter gates everyone the moment idx has a bad day.
86
+ if (!appUser) {
87
+ return {
88
+ hasMissing: false, missingRows: [], missingCustomFieldIds: [], missingCount: 0,
89
+ };
90
+ }
91
+
92
+ // Rows are preserved so the gate lays out exactly as the site configured it; fully-satisfied
93
+ // rows drop out, partially-satisfied ones keep only their missing columns, and every surviving
94
+ // column is forced required — the gate has no optional questions.
95
+ const missingRows = getAsArray({ fieldRows }, 'fieldRows')
96
+ .map((row) => getAsArray({ row }, 'row')
97
+ .filter((col) => col && isColumnMissing({ appUser, col }))
98
+ .map((col) => ({ ...col, required: true })))
99
+ .filter((row) => row.length);
100
+
101
+ const missingCustomFieldIds = missingRows.reduce((ids, row) => {
102
+ row.forEach((col) => ids.push(col.id));
103
+ return ids;
104
+ }, []);
105
+
106
+ return {
107
+ hasMissing: Boolean(missingCustomFieldIds.length),
108
+ missingRows,
109
+ missingCustomFieldIds,
110
+ missingCount: missingCustomFieldIds.length,
111
+ };
112
+ };
113
+
114
+ module.exports.SUPPORTED_TYPES = SUPPORTED_TYPES;