@mindful-web/marko-web-identity-x 1.87.0 → 1.89.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/CLAUDE.md +46 -3
- package/browser/form/fields/custom-boolean-yes-no.vue +99 -0
- package/browser/profile.vue +18 -6
- package/components/access.marko.js +2 -2
- package/components/comment-stream.marko.js +2 -2
- package/components/context.marko.js +2 -2
- package/components/form-access.marko.js +2 -2
- package/components/form-authenticate.marko.js +2 -2
- package/components/form-change-email.marko.js +2 -2
- package/components/form-login.marko.js +2 -2
- package/components/form-logout.marko.js +2 -2
- package/components/form-profile.marko.js +2 -2
- package/components/form-register.marko.js +2 -2
- package/components/google-init.marko.js +2 -2
- package/components/google-sign-in-button.marko.js +2 -2
- package/components/identify.marko.js +2 -2
- package/components/non-auth-identify.marko.js +2 -2
- package/components/progressive-profile-form.marko.js +2 -2
- package/components/subscribe.marko.js +2 -2
- package/package.json +2 -2
- package/routes/profile.js +25 -12
- package/test/utils/build-boolean-answers-input.spec.js +62 -0
- package/test/utils/overlay-submitted-boolean-answers.spec.js +52 -0
- package/utils/build-boolean-answers-input.js +32 -0
- package/utils/overlay-submitted-boolean-answers.js +37 -0
package/CLAUDE.md
CHANGED
|
@@ -138,6 +138,49 @@ seam Omeda uses for rapid-identify + subscription sync. **Note:** `onLogout` cur
|
|
|
138
138
|
in the tokenless logout branch — a "fire `onLogout` always" change would let the `g_state` clear
|
|
139
139
|
become a hook rather than inline route code.
|
|
140
140
|
|
|
141
|
+
## Boolean questions have three states — added 2026-08
|
|
142
|
+
|
|
143
|
+
`AppUserCustomBooleanFieldAnswer.answer` is a **nullable** `Boolean`, and the schema says so:
|
|
144
|
+
*"A null value signifies a non answer. It's up to the implementing components to account for this."*
|
|
145
|
+
`hasAnswered` is literally `answer != null`. The three states are `true`, `false`, and *never
|
|
146
|
+
answered* — and on a subscription question the third is not a "no", it is silence.
|
|
147
|
+
|
|
148
|
+
The write side has no null: `UpdateAppUserCustomBooleanAnswer { fieldId: String!, value: Boolean! }`
|
|
149
|
+
on both identity-x and idx-compat. **Omission is the encoding for "no statement"**, and it is
|
|
150
|
+
load-bearing at every layer below this package: an absent entry in the `answers` array is a no-op,
|
|
151
|
+
`marko-web-omeda-identity-x/rapid-identify.js` skips `hasAnswered: false` answers *and* anything
|
|
152
|
+
outside `restrictAnswersToFieldIds`, and the Omeda resolver does `if (optedIn == null) return;`.
|
|
153
|
+
|
|
154
|
+
**The profile form used to break this in two places, and no longer does.**
|
|
155
|
+
|
|
156
|
+
- `browser/form/fields/custom-boolean-yes-no.vue` — the profile form's subscription control. Two
|
|
157
|
+
radios, and a `null` value selects **neither**, so unanswered is visually distinct from answered-
|
|
158
|
+
no. Emits the boolean, never null: a user can move between yes and no but cannot un-answer.
|
|
159
|
+
`required` is set on both radios and the browser validates them as a group — strictly better than
|
|
160
|
+
the checkbox it replaced, which could only ever be satisfied by opting *in*.
|
|
161
|
+
`browser/profile.vue`'s `onCustomBooleanChange` **assigns** the emitted answer; it used to
|
|
162
|
+
`!answer`, which on a `null` would read a "no" click as `true`.
|
|
163
|
+
- `utils/build-boolean-answers-input.js` — `routes/profile.js` used to map every rendered answer
|
|
164
|
+
with `value: Boolean(fieldAnswer.answer)` and push every field id into `submittedFieldIds`. A
|
|
165
|
+
member who opened /user/profile to fix a typo and never touched the subscription block therefore
|
|
166
|
+
sent `optedIn: false` / `Receive: 0` for every question they had never answered. The util drops
|
|
167
|
+
`answer == null` before both the mutation and `submittedFieldIds`, so the Omeda hook's
|
|
168
|
+
`restrictAnswersToFieldIds` drops them too. Spec:
|
|
169
|
+
`test/utils/build-boolean-answers-input.spec.js`.
|
|
170
|
+
|
|
171
|
+
**This is fleet-wide and unconditional** — no config flag; it arrives on a `mindful-web` dep bump.
|
|
172
|
+
|
|
173
|
+
**Scope is `/user/profile` only.** `custom-boolean.vue` (the checkbox) is untouched and still
|
|
174
|
+
renders in `custom-column.vue` (register/login required rows), `access.vue`/`download.vue` (gating),
|
|
175
|
+
and the theme's `idx-newsletter-form/*`. Those forms only ever create opt-ins on user creation, and
|
|
176
|
+
a signup box reads better as a checkbox.
|
|
177
|
+
|
|
178
|
+
**What this cannot fix.** For channel-backed booleans (`_kind: EMAIL|MAGAZINE`), idx-compat derives
|
|
179
|
+
`answer` from the member's stored `subscriptions[].active`
|
|
180
|
+
(`idx-compat/src/resolvers/app-user.ts:76-101`) — so once *any* subscription doc exists,
|
|
181
|
+
`active: false` is indistinguishable from an explicit no and renders with **No** pre-selected.
|
|
182
|
+
Channels have no unanswered state. This prevents new damage; it cannot un-answer history.
|
|
183
|
+
|
|
141
184
|
## Google Sign-In (One-Tap + button) — folded in 2026-07
|
|
142
185
|
|
|
143
186
|
Verifies a Google Identity Services (GIS) credential server-side and drops the user into the
|
|
@@ -348,7 +391,7 @@ configurations that are each a user-facing trap:
|
|
|
348
391
|
| Boot check | Why the runtime failure is worse than a failed deploy |
|
|
349
392
|
| --- | --- |
|
|
350
393
|
| `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
|
|
351
|
-
| `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer.
|
|
394
|
+
| `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. The profile form now uses an explicit yes/no control that would satisfy this (see *Boolean questions have three states*), but it is not wired into `custom-column.vue` and the check still stands — re-allow only once the gate actually renders that control. |
|
|
352
395
|
| any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
|
|
353
396
|
| id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
|
|
354
397
|
| rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
|
|
@@ -439,8 +482,8 @@ wholesale: 18 of the first 48 production submitters on athleticbusiness had thei
|
|
|
439
482
|
subscription killed (`Receive: 0`, "Controlled Kills") at their exact submit second — the
|
|
440
483
|
2026-08-19 incident. The spec asserting the payload shape lives in
|
|
441
484
|
`marko-web-omeda-identity-x/test/rapid-identify.spec.js`. The profile route (`routes/profile.js`)
|
|
442
|
-
still passes booleans deliberately — its form renders the subscription
|
|
443
|
-
the
|
|
485
|
+
still passes booleans deliberately — its form renders the subscription questions — but it now only
|
|
486
|
+
passes the ones the member has actually answered; see *Boolean questions have three states*.
|
|
444
487
|
|
|
445
488
|
### Bot protection — verify, then discard the write rather than the visitor
|
|
446
489
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form-group class-name="custom-boolean-yes-no">
|
|
3
|
+
<fieldset class="custom-boolean-yes-no__fieldset">
|
|
4
|
+
<legend class="custom-boolean-yes-no__legend">
|
|
5
|
+
<span v-html="message" />
|
|
6
|
+
<strong v-if="required" class="text-danger">*</strong>
|
|
7
|
+
</legend>
|
|
8
|
+
<div class="custom-boolean-yes-no__options">
|
|
9
|
+
<div
|
|
10
|
+
v-for="option in options"
|
|
11
|
+
:key="option.value"
|
|
12
|
+
class="custom-control custom-radio custom-control-inline"
|
|
13
|
+
>
|
|
14
|
+
<input
|
|
15
|
+
:id="`custom-boolean-${id}-${option.value}`"
|
|
16
|
+
:name="`custom-boolean-${id}`"
|
|
17
|
+
:checked="value === option.answer"
|
|
18
|
+
:disabled="disabled"
|
|
19
|
+
:required="required"
|
|
20
|
+
:value="option.value"
|
|
21
|
+
type="radio"
|
|
22
|
+
class="custom-control-input"
|
|
23
|
+
@change="$emit('input', option.answer)"
|
|
24
|
+
>
|
|
25
|
+
<label
|
|
26
|
+
class="custom-control-label"
|
|
27
|
+
:for="`custom-boolean-${id}-${option.value}`"
|
|
28
|
+
>{{ option.label }}</label>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</fieldset>
|
|
32
|
+
</form-group>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import FormGroup from '../common/form-group.vue';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An explicit yes/no answer for a boolean (subscription) question.
|
|
40
|
+
*
|
|
41
|
+
* Unlike `custom-boolean.vue` — the checkbox this replaces on the profile form — a `null` value
|
|
42
|
+
* selects NEITHER radio, so "never answered" is visually distinct from "answered no". The route
|
|
43
|
+
* then omits unanswered questions from the write rather than sending them as `false`, which is
|
|
44
|
+
* what keeps a profile save from unsubscribing someone who never touched the question.
|
|
45
|
+
*
|
|
46
|
+
* A user can move between yes and no but cannot return a question to unanswered; that is
|
|
47
|
+
* deliberate. `required` is set on both radios, which the browser validates as a group — strictly
|
|
48
|
+
* better than a required checkbox, which can only ever be satisfied by opting IN.
|
|
49
|
+
*
|
|
50
|
+
* The asterisk is inlined rather than reusing `form/common/form-label.vue`: the question here
|
|
51
|
+
* labels a radio GROUP, so it belongs in a `<legend>`, and that component always renders a
|
|
52
|
+
* `<label>` element.
|
|
53
|
+
*/
|
|
54
|
+
export default {
|
|
55
|
+
components: { FormGroup },
|
|
56
|
+
props: {
|
|
57
|
+
id: {
|
|
58
|
+
type: String,
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
disabled: {
|
|
62
|
+
type: Boolean,
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* `true`, `false`, or `null` when the question has never been answered.
|
|
67
|
+
* Deliberately untyped so `null` passes through without a Vue type warning.
|
|
68
|
+
*/
|
|
69
|
+
value: {
|
|
70
|
+
default: null,
|
|
71
|
+
validator: (v) => v === null || typeof v === 'boolean',
|
|
72
|
+
},
|
|
73
|
+
required: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
},
|
|
77
|
+
message: {
|
|
78
|
+
type: String,
|
|
79
|
+
required: true,
|
|
80
|
+
},
|
|
81
|
+
yesLabel: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: 'Yes',
|
|
84
|
+
},
|
|
85
|
+
noLabel: {
|
|
86
|
+
type: String,
|
|
87
|
+
default: 'No',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
computed: {
|
|
91
|
+
options() {
|
|
92
|
+
return [
|
|
93
|
+
{ value: 'yes', answer: true, label: this.yesLabel },
|
|
94
|
+
{ value: 'no', answer: false, label: this.noLabel },
|
|
95
|
+
];
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
</script>
|
package/browser/profile.vue
CHANGED
|
@@ -173,12 +173,14 @@
|
|
|
173
173
|
:key="fieldAnswer.id"
|
|
174
174
|
class="col-12"
|
|
175
175
|
>
|
|
176
|
-
<custom-boolean
|
|
176
|
+
<custom-boolean-yes-no
|
|
177
177
|
:id="fieldAnswer.id"
|
|
178
178
|
:message="fieldAnswer.field.label"
|
|
179
179
|
:required="fieldAnswer.field.required"
|
|
180
180
|
:value="fieldAnswer.answer"
|
|
181
|
-
|
|
181
|
+
:yes-label="translate({ key: 'Yes' })"
|
|
182
|
+
:no-label="translate({ key: 'No' })"
|
|
183
|
+
@input="onCustomBooleanChange(fieldAnswer.id, $event)"
|
|
182
184
|
/>
|
|
183
185
|
</div>
|
|
184
186
|
</div>
|
|
@@ -264,7 +266,7 @@ import regionCountryCodes from './utils/region-country-codes';
|
|
|
264
266
|
|
|
265
267
|
import AddressBlock from './form/address-block.vue';
|
|
266
268
|
import FormConsent from './form/consent.vue';
|
|
267
|
-
import
|
|
269
|
+
import CustomBooleanYesNo from './form/fields/custom-boolean-yes-no.vue';
|
|
268
270
|
import CustomSelect from './form/fields/custom-select.vue';
|
|
269
271
|
import CustomText from './form/fields/custom-text.vue';
|
|
270
272
|
import GivenName from './form/fields/given-name.vue';
|
|
@@ -285,7 +287,7 @@ const { isArray } = Array;
|
|
|
285
287
|
export default {
|
|
286
288
|
components: {
|
|
287
289
|
AddressBlock,
|
|
288
|
-
|
|
290
|
+
CustomBooleanYesNo,
|
|
289
291
|
CustomSelect,
|
|
290
292
|
CustomText,
|
|
291
293
|
GivenName,
|
|
@@ -631,9 +633,13 @@ export default {
|
|
|
631
633
|
/**
|
|
632
634
|
*
|
|
633
635
|
*/
|
|
634
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Assigns the answer the control emitted. Deliberately NOT a toggle of the stored value:
|
|
638
|
+
* the yes/no control has three input states and `!null` would read a "no" click as `true`.
|
|
639
|
+
*/
|
|
640
|
+
onCustomBooleanChange(id, answer) {
|
|
635
641
|
const objIndex = this.customBooleanFieldAnswers.findIndex(((obj) => obj.id === id));
|
|
636
|
-
|
|
642
|
+
if (objIndex === -1) return;
|
|
637
643
|
this.customBooleanFieldAnswers[objIndex].answer = answer;
|
|
638
644
|
|
|
639
645
|
this.user.customBooleanFieldAnswers = this.customBooleanFieldAnswers;
|
|
@@ -652,6 +658,8 @@ export default {
|
|
|
652
658
|
'To continue modifying your profile,': 'Para continuar editando seu perfil,',
|
|
653
659
|
'To return to the home page, ': 'Para voltar à página inicial, ',
|
|
654
660
|
'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
|
|
661
|
+
Yes: 'Sim',
|
|
662
|
+
No: 'Não',
|
|
655
663
|
},
|
|
656
664
|
es: {
|
|
657
665
|
'An error occurred': 'Se produjo un error:',
|
|
@@ -663,6 +671,8 @@ export default {
|
|
|
663
671
|
'To continue modifying your profile,': 'Para continuar modificando tu perfil, ',
|
|
664
672
|
'To return to the home page, ': 'Para volver a la página de inicio, ',
|
|
665
673
|
'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
|
|
674
|
+
Yes: 'Sí',
|
|
675
|
+
No: 'No',
|
|
666
676
|
},
|
|
667
677
|
en: {
|
|
668
678
|
'An error occurred: ': 'An error occurred: ',
|
|
@@ -674,6 +684,8 @@ export default {
|
|
|
674
684
|
'To continue modifying your profile,': 'To continue modifying your profile,',
|
|
675
685
|
'Your profile has been saved.': 'Your profile has been saved.',
|
|
676
686
|
'You must be logged in to modify your user profile.': 'You must be logged in to modify your user profile.',
|
|
687
|
+
Yes: 'Yes',
|
|
688
|
+
No: 'No',
|
|
677
689
|
},
|
|
678
690
|
};
|
|
679
691
|
const value = (i18n[lang] && i18n[lang][key]) ? i18n[lang][key] : i18n.en[key] || '';
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/access.marko",
|
|
6
6
|
marko_component = require("./access.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -53,7 +53,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
53
53
|
}, marko_component);
|
|
54
54
|
|
|
55
55
|
marko_template.meta = {
|
|
56
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
56
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/access.marko",
|
|
57
57
|
component: "./access.marko",
|
|
58
58
|
tags: [
|
|
59
59
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/comment-stream.marko",
|
|
6
6
|
marko_component = require("./comment-stream.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -91,7 +91,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
91
91
|
}, marko_component);
|
|
92
92
|
|
|
93
93
|
marko_template.meta = {
|
|
94
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
94
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/comment-stream.marko",
|
|
95
95
|
component: "./comment-stream.marko",
|
|
96
96
|
tags: [
|
|
97
97
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/context.marko",
|
|
6
6
|
marko_component = require("./context.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
|
|
@@ -49,7 +49,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
49
49
|
}, marko_component);
|
|
50
50
|
|
|
51
51
|
marko_template.meta = {
|
|
52
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
52
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/context.marko",
|
|
53
53
|
component: "./context.marko",
|
|
54
54
|
tags: [
|
|
55
55
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-access.marko",
|
|
6
6
|
marko_component = require("./form-access.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
@@ -179,7 +179,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
179
179
|
}, marko_component);
|
|
180
180
|
|
|
181
181
|
marko_template.meta = {
|
|
182
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
182
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-access.marko",
|
|
183
183
|
component: "./form-access.marko",
|
|
184
184
|
tags: [
|
|
185
185
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-authenticate.marko",
|
|
6
6
|
marko_component = require("./form-authenticate.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -81,7 +81,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
81
81
|
}, marko_component);
|
|
82
82
|
|
|
83
83
|
marko_template.meta = {
|
|
84
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
84
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-authenticate.marko",
|
|
85
85
|
component: "./form-authenticate.marko",
|
|
86
86
|
tags: [
|
|
87
87
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-change-email.marko",
|
|
6
6
|
marko_component = require("./form-change-email.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -59,7 +59,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
59
59
|
}, marko_component);
|
|
60
60
|
|
|
61
61
|
marko_template.meta = {
|
|
62
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
62
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-change-email.marko",
|
|
63
63
|
component: "./form-change-email.marko",
|
|
64
64
|
tags: [
|
|
65
65
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-login.marko",
|
|
6
6
|
marko_component = require("./form-login.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -92,7 +92,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
92
92
|
}, marko_component);
|
|
93
93
|
|
|
94
94
|
marko_template.meta = {
|
|
95
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
95
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-login.marko",
|
|
96
96
|
component: "./form-login.marko",
|
|
97
97
|
tags: [
|
|
98
98
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-logout.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
8
8
|
defaultValue = module_defaultValue.default || module_defaultValue,
|
|
@@ -29,7 +29,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
marko_template.meta = {
|
|
32
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
32
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-logout.marko",
|
|
33
33
|
tags: [
|
|
34
34
|
"@mindful-web/marko-web/components/browser-component.marko"
|
|
35
35
|
]
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-profile.marko",
|
|
6
6
|
marko_component = require("./form-profile.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -84,7 +84,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
84
84
|
}, marko_component);
|
|
85
85
|
|
|
86
86
|
marko_template.meta = {
|
|
87
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
87
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-profile.marko",
|
|
88
88
|
component: "./form-profile.marko",
|
|
89
89
|
tags: [
|
|
90
90
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-register.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
marko_assign = require("marko/dist/runtime/helpers/assign"),
|
|
8
8
|
marko_web_identity_x_form_login_template = require("./form-login.marko"),
|
|
@@ -25,7 +25,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
marko_template.meta = {
|
|
28
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
28
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-register.marko",
|
|
29
29
|
tags: [
|
|
30
30
|
"./form-login.marko"
|
|
31
31
|
]
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/google-init.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
marko_attr = require("marko/dist/runtime/html/helpers/attr"),
|
|
8
8
|
marko_str = require("marko/dist/runtime/helpers/to-string");
|
|
@@ -239,5 +239,5 @@ marko_template._ = marko_renderer(render, {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
marko_template.meta = {
|
|
242
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
242
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/google-init.marko"
|
|
243
243
|
};
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/google-sign-in-button.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
|
|
8
8
|
marko_escapeXml = helpers_escape_xml.x,
|
|
@@ -40,5 +40,5 @@ marko_template._ = marko_renderer(render, {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
marko_template.meta = {
|
|
43
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
43
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/google-sign-in-button.marko"
|
|
44
44
|
};
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/identify.marko",
|
|
6
6
|
marko_component = require("./identify.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -50,7 +50,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
50
50
|
}, marko_component);
|
|
51
51
|
|
|
52
52
|
marko_template.meta = {
|
|
53
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
53
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/identify.marko",
|
|
54
54
|
component: "./identify.marko",
|
|
55
55
|
tags: [
|
|
56
56
|
"@mindful-web/marko-web-gtm/components/push.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/non-auth-identify.marko",
|
|
6
6
|
marko_component = require("./non-auth-identify.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
|
|
@@ -45,7 +45,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
45
45
|
}, marko_component);
|
|
46
46
|
|
|
47
47
|
marko_template.meta = {
|
|
48
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
48
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/non-auth-identify.marko",
|
|
49
49
|
component: "./non-auth-identify.marko",
|
|
50
50
|
tags: [
|
|
51
51
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/progressive-profile-form.marko",
|
|
6
6
|
marko_component = require("./progressive-profile-form.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
@@ -137,7 +137,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
137
137
|
}, marko_component);
|
|
138
138
|
|
|
139
139
|
marko_template.meta = {
|
|
140
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
140
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/progressive-profile-form.marko",
|
|
141
141
|
component: "./progressive-profile-form.marko",
|
|
142
142
|
tags: [
|
|
143
143
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -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-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/subscribe.marko",
|
|
6
6
|
marko_component = require("./subscribe.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -73,7 +73,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
73
73
|
}, marko_component);
|
|
74
74
|
|
|
75
75
|
marko_template.meta = {
|
|
76
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
76
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/subscribe.marko",
|
|
77
77
|
component: "./subscribe.marko",
|
|
78
78
|
tags: [
|
|
79
79
|
"@mindful-web/marko-web/components/browser-component.marko",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindful-web/marko-web-identity-x",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.89.0",
|
|
4
4
|
"description": "Marko Wrapper for IdentityX",
|
|
5
5
|
"repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-identity-x",
|
|
6
6
|
"author": "Josh Worden <josh@parameter1.com>",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"chai": "^4.3.7",
|
|
44
44
|
"mocha": "^6.2.3"
|
|
45
45
|
},
|
|
46
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "ef9a5dabc2cbaafb948233f37763a1bfd792b5a4"
|
|
47
47
|
}
|
package/routes/profile.js
CHANGED
|
@@ -3,6 +3,8 @@ const { asyncRoute } = require('@mindful-web/utils');
|
|
|
3
3
|
const { getAsArray, getAsObject } = require('@mindful-web/object-path');
|
|
4
4
|
const userFragment = require('../api/fragments/active-user');
|
|
5
5
|
const callHooksFor = require('../utils/call-hooks-for');
|
|
6
|
+
const buildBooleanAnswersInput = require('../utils/build-boolean-answers-input');
|
|
7
|
+
const overlaySubmittedBooleanAnswers = require('../utils/overlay-submitted-boolean-answers');
|
|
6
8
|
|
|
7
9
|
const mutation = gql`
|
|
8
10
|
mutation UpdateUserProfile($input: UpdateOwnAppUserMutationInput!) {
|
|
@@ -119,20 +121,27 @@ module.exports = asyncRoute(async (req, res) => {
|
|
|
119
121
|
// syncs (Omeda rapid-identify) scope their answer payload to what the form displayed — stored
|
|
120
122
|
// answers for fields this form never rendered must not be rebroadcast as writes.
|
|
121
123
|
const submittedFieldIds = [];
|
|
124
|
+
// The boolean values this request submitted, by field id — overlaid onto the refreshed user
|
|
125
|
+
// before the hooks fire (see overlaySubmittedBooleanAnswers for the one-save-behind race this
|
|
126
|
+
// guards against).
|
|
127
|
+
const submittedBooleanValues = new Map();
|
|
128
|
+
|
|
129
|
+
// An answer can be true, false or null. A null is a NON-answer and is DROPPED here rather than
|
|
130
|
+
// written as `false` — see build-boolean-answers-input for why that distinction is the whole
|
|
131
|
+
// difference between "the user said no" and "we unsubscribed someone who never answered".
|
|
132
|
+
// Unanswered ids never reach `submittedFieldIds` either, so the Omeda hook's
|
|
133
|
+
// `restrictAnswersToFieldIds` drops them from rapid-identify too.
|
|
134
|
+
const customBooleanFieldsInput = buildBooleanAnswersInput(
|
|
135
|
+
customBooleanFieldAnswers,
|
|
136
|
+
customFieldIds,
|
|
137
|
+
);
|
|
122
138
|
|
|
123
|
-
if (
|
|
139
|
+
if (customBooleanFieldsInput.length) {
|
|
124
140
|
// only update custom questions when there some :)
|
|
125
|
-
const customBooleanFieldsInput = customBooleanFieldAnswers.map((fieldAnswer) => ({
|
|
126
|
-
fieldId: fieldAnswer.field.id,
|
|
127
|
-
// can either be true, false or null. convert null to false.
|
|
128
|
-
// the form submit is effectively answers the question.
|
|
129
|
-
value: Boolean(fieldAnswer.answer),
|
|
130
|
-
})).filter(
|
|
131
|
-
customFieldIds.length > 0
|
|
132
|
-
? ({ fieldId }) => customFieldIds.includes(fieldId)
|
|
133
|
-
: () => true,
|
|
134
|
-
);
|
|
135
141
|
submittedFieldIds.push(...customBooleanFieldsInput.map(({ fieldId }) => fieldId));
|
|
142
|
+
customBooleanFieldsInput.forEach(({ fieldId, value }) => {
|
|
143
|
+
submittedBooleanValues.set(fieldId, value);
|
|
144
|
+
});
|
|
136
145
|
await identityX.client.mutate({
|
|
137
146
|
mutation: customBooleanFieldsMutation,
|
|
138
147
|
variables: { input: { answers: customBooleanFieldsInput } },
|
|
@@ -184,7 +193,11 @@ module.exports = asyncRoute(async (req, res) => {
|
|
|
184
193
|
additionalEventData,
|
|
185
194
|
...(additionalEventData || {}),
|
|
186
195
|
req,
|
|
187
|
-
user
|
|
196
|
+
// The refreshed user can carry PRE-save channel checkbox answers (they resolve from the
|
|
197
|
+
// member doc's embedded subscription copy, which materializes asynchronously) — so the
|
|
198
|
+
// values this request submitted are overlaid, ensuring provider hooks push what the user
|
|
199
|
+
// just said rather than unsubscribing them with their own stale state.
|
|
200
|
+
user: overlaySubmittedBooleanAnswers(user, submittedBooleanValues),
|
|
188
201
|
submittedFieldIds,
|
|
189
202
|
});
|
|
190
203
|
const entity = await identityX.generateEntityId({ userId: user.id });
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { describe, it } = require('mocha');
|
|
2
|
+
const { expect } = require('chai');
|
|
3
|
+
const build = require('../../utils/build-boolean-answers-input');
|
|
4
|
+
|
|
5
|
+
const answer = ({ id, value = null }) => ({
|
|
6
|
+
id: `ans-${id}`,
|
|
7
|
+
hasAnswered: value != null,
|
|
8
|
+
answer: value,
|
|
9
|
+
value,
|
|
10
|
+
field: { id, active: true },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('utils/build-boolean-answers-input', () => {
|
|
14
|
+
it('omits an unanswered question rather than writing it as false', () => {
|
|
15
|
+
// The whole point: this is what stops /user/profile from unsubscribing someone who never
|
|
16
|
+
// touched the subscription block.
|
|
17
|
+
expect(build([answer({ id: 'chan-mag' })])).to.deep.equal([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('preserves an explicit no as false', () => {
|
|
21
|
+
expect(build([answer({ id: 'chan-mag', value: false })]))
|
|
22
|
+
.to.deep.equal([{ fieldId: 'chan-mag', value: false }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserves an explicit yes as true', () => {
|
|
26
|
+
expect(build([answer({ id: 'chan-mag', value: true })]))
|
|
27
|
+
.to.deep.equal([{ fieldId: 'chan-mag', value: true }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps answered questions when mixed with unanswered ones', () => {
|
|
31
|
+
const answers = [
|
|
32
|
+
answer({ id: 'a', value: true }),
|
|
33
|
+
answer({ id: 'b' }),
|
|
34
|
+
answer({ id: 'c', value: false }),
|
|
35
|
+
];
|
|
36
|
+
expect(build(answers)).to.deep.equal([
|
|
37
|
+
{ fieldId: 'a', value: true },
|
|
38
|
+
{ fieldId: 'c', value: false },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('applies the writable field id allowlist', () => {
|
|
43
|
+
const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
|
|
44
|
+
expect(build(answers, ['b'])).to.deep.equal([{ fieldId: 'b', value: false }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('writes every answered field when the allowlist is empty', () => {
|
|
48
|
+
const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
|
|
49
|
+
expect(build(answers, [])).to.have.lengthOf(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores answers carrying no field', () => {
|
|
53
|
+
expect(build([{ answer: true }, answer({ id: 'a', value: true })]))
|
|
54
|
+
.to.deep.equal([{ fieldId: 'a', value: true }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns an empty array for missing or empty input', () => {
|
|
58
|
+
expect(build()).to.deep.equal([]);
|
|
59
|
+
expect(build(null)).to.deep.equal([]);
|
|
60
|
+
expect(build([])).to.deep.equal([]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const { describe, it } = require('mocha');
|
|
2
|
+
const { expect } = require('chai');
|
|
3
|
+
const overlay = require('../../utils/overlay-submitted-boolean-answers');
|
|
4
|
+
|
|
5
|
+
const answer = ({ id, value, hasAnswered = true }) => ({
|
|
6
|
+
id: `ans-${id}`,
|
|
7
|
+
hasAnswered,
|
|
8
|
+
answer: value,
|
|
9
|
+
value,
|
|
10
|
+
field: { id, active: true },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('utils/overlay-submitted-boolean-answers', () => {
|
|
14
|
+
it('replaces a stale answer with the submitted value (the one-save-behind race)', () => {
|
|
15
|
+
// The member checked the box; the refreshed user still carries the pre-save `false`.
|
|
16
|
+
const user = {
|
|
17
|
+
email: 'x@y.com',
|
|
18
|
+
customBooleanFieldAnswers: [answer({ id: 'chan-mag', value: false })],
|
|
19
|
+
};
|
|
20
|
+
const out = overlay(user, new Map([['chan-mag', true]]));
|
|
21
|
+
expect(out.customBooleanFieldAnswers[0])
|
|
22
|
+
.to.include({ answer: true, value: true, hasAnswered: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('answers a previously-unanswered field the request submitted', () => {
|
|
26
|
+
const unanswered = { field: { id: 'chan-new', active: true } };
|
|
27
|
+
const user = { customBooleanFieldAnswers: [unanswered] };
|
|
28
|
+
const out = overlay(user, new Map([['chan-new', false]]));
|
|
29
|
+
expect(out.customBooleanFieldAnswers[0])
|
|
30
|
+
.to.include({ answer: false, value: false, hasAnswered: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('leaves answers for fields the request did not submit untouched', () => {
|
|
34
|
+
const untouched = answer({ id: 'other', value: true });
|
|
35
|
+
const user = { customBooleanFieldAnswers: [untouched, answer({ id: 'chan-mag', value: false })] };
|
|
36
|
+
const out = overlay(user, new Map([['chan-mag', true]]));
|
|
37
|
+
expect(out.customBooleanFieldAnswers[0]).to.equal(untouched);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns the same user when nothing was submitted', () => {
|
|
41
|
+
const user = { customBooleanFieldAnswers: [answer({ id: 'a', value: true })] };
|
|
42
|
+
expect(overlay(user, new Map())).to.equal(user);
|
|
43
|
+
expect(overlay(user, undefined)).to.equal(user);
|
|
44
|
+
expect(overlay(null, new Map([['a', true]]))).to.equal(null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not mutate the original user', () => {
|
|
48
|
+
const user = { customBooleanFieldAnswers: [answer({ id: 'chan-mag', value: false })] };
|
|
49
|
+
overlay(user, new Map([['chan-mag', true]]));
|
|
50
|
+
expect(user.customBooleanFieldAnswers[0].answer).to.equal(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marshals submitted boolean field answers into `UpdateAppUserCustomBooleanAnswer` input.
|
|
3
|
+
*
|
|
4
|
+
* A boolean answer has THREE states, not two: `true`, `false`, and `null` (never answered —
|
|
5
|
+
* `hasAnswered` is `answer != null`). This drops the third rather than collapsing it to `false`.
|
|
6
|
+
*
|
|
7
|
+
* Why that matters: an unanswered question written as `false` is a *statement*. The profile route
|
|
8
|
+
* feeds its returned field ids to `onUserProfileUpdate` as `restrictAnswersToFieldIds`, and
|
|
9
|
+
* `marko-web-omeda-identity-x/rapid-identify.js` turns a deployment-type boolean into
|
|
10
|
+
* `optedIn: false` and a product boolean into `receive: false` (`Receive: 0`, a Controlled Kill).
|
|
11
|
+
* So a member who opened /user/profile to fix a typo and never touched the subscription block
|
|
12
|
+
* would unsubscribe themselves from every question they had never answered.
|
|
13
|
+
*
|
|
14
|
+
* Omission is the supported encoding for "no statement" at every layer below this one: the
|
|
15
|
+
* mutation input is `value: Boolean!` (there is no null to send), an absent entry in the `answers`
|
|
16
|
+
* array is a no-op, rapid-identify skips anything outside `restrictAnswersToFieldIds`, and the
|
|
17
|
+
* Omeda resolver does `if (optedIn == null) return;`.
|
|
18
|
+
*
|
|
19
|
+
* @param {object[]} answers The posted `customBooleanFieldAnswers`.
|
|
20
|
+
* @param {string[]} [customFieldIds] When non-empty, the allowlist of writable field ids.
|
|
21
|
+
* @returns {{ fieldId: string, value: boolean }[]} Answered, writable answers only.
|
|
22
|
+
*/
|
|
23
|
+
module.exports = (answers, customFieldIds = []) => (Array.isArray(answers) ? answers : [])
|
|
24
|
+
// A null answer is a NON-answer. Never coerce it to `false`.
|
|
25
|
+
.filter((fieldAnswer) => fieldAnswer && fieldAnswer.field && fieldAnswer.answer != null)
|
|
26
|
+
.map((fieldAnswer) => ({
|
|
27
|
+
fieldId: fieldAnswer.field.id,
|
|
28
|
+
value: Boolean(fieldAnswer.answer),
|
|
29
|
+
}))
|
|
30
|
+
.filter(customFieldIds.length > 0
|
|
31
|
+
? ({ fieldId }) => customFieldIds.includes(fieldId)
|
|
32
|
+
: () => true);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { getAsArray } = require('@mindful-web/object-path');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a copy of the user whose `customBooleanFieldAnswers` reflect the values THIS request
|
|
5
|
+
* submitted, overriding whatever the refreshed user carries for those fields.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (the one-save-behind race, proven live 2026-08-25 on athleticbusiness):
|
|
8
|
+
* channel checkbox writes land in the audience `subscriptions` COLLECTION, but the boolean
|
|
9
|
+
* answers on a fetched user resolve from the member document's EMBEDDED subscription copy,
|
|
10
|
+
* which only catches up via an async materialization queue. So the user returned by the
|
|
11
|
+
* profile route's final mutation can still carry the PRE-save checkbox state — idx stored
|
|
12
|
+
* `true` while the provider hook pushed `Receive: 0` to Omeda one second later. Overlaying the
|
|
13
|
+
* submitted values means provider syncs push what the user just said, not what the
|
|
14
|
+
* materializer has caught up to.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} user The (possibly stale) user returned after the save.
|
|
17
|
+
* @param {Map<string, boolean>} submittedValues field id -> submitted boolean value.
|
|
18
|
+
* @returns {object} The user, with submitted boolean answers overlaid. Same reference when
|
|
19
|
+
* nothing was submitted.
|
|
20
|
+
*/
|
|
21
|
+
module.exports = (user, submittedValues) => {
|
|
22
|
+
if (!user || !submittedValues || !submittedValues.size) return user;
|
|
23
|
+
return {
|
|
24
|
+
...user,
|
|
25
|
+
customBooleanFieldAnswers: getAsArray(user, 'customBooleanFieldAnswers').map((ans) => {
|
|
26
|
+
const fieldId = ans && ans.field && ans.field.id;
|
|
27
|
+
if (!fieldId || !submittedValues.has(fieldId)) return ans;
|
|
28
|
+
const value = Boolean(submittedValues.get(fieldId));
|
|
29
|
+
return {
|
|
30
|
+
...ans,
|
|
31
|
+
answer: value,
|
|
32
|
+
value,
|
|
33
|
+
hasAnswered: true,
|
|
34
|
+
};
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
};
|