Setting the file. One moment.
Member Outcome Registry · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page lib/ member-outcome-registry.js
JavaScript · 301 lines · 14 KB
14
const
MEMBER_OUTCOME
=
Object.
freeze
({
15 WRITTEN: 'member-written' ,
16 RECONCILED_EXISTING: 'member-reconciled-existing' ,
17 NOT_WRITTEN_NO_EMAIL: 'member-not-written-no-email' ,
18 RECONCILIATION_AMBIGUOUS: 'member-reconciliation-ambiguous' ,
19 TARGET_READ_FAILED: 'member-target-read-failed' ,
20 ALREADY_EXISTS_UNRECONCILED: 'member-already-exists-unreconciled' ,
21 FAILED: 'member-failed' ,
22 });
23 const MEMBER_OUTCOMES = Object. freeze (Object. values ( MEMBER_OUTCOME ));
24
25 // Supplemental findings: zero or more per customer. They never change the primary outcome.
26 const MEMBER_FINDING = Object. freeze ({
27 PHONE_NOT_NORMALIZED: 'member-phone-not-normalized' ,
28 PHONE_COLLISION_DROPPED: 'member-phone-collision-dropped' ,
29 CREATE_RETRIED: 'member-create-retried-after-transient-error' ,
30 CROSSWALK_ROW_STALE: 'member-crosswalk-row-stale' ,
31 // Create Member refuses contact.company (live 400 "Unexpected value for StringValue", though the
32 // docs type it as a string), so company/jobTitle are written after the create through Contacts V5.
33 COMPANY_WRITTEN_AFTER_CREATE: 'member-company-written-after-create' ,
34 COMPANY_NOT_WRITTEN: 'member-company-not-written' ,
35 LOGIN_EMAIL_INVALID: 'member-login-email-invalid' ,
36 });
37 const MEMBER_FINDINGS = Object. freeze (Object. values ( MEMBER_FINDING ));
38
39 // Where the member pass's journal rows live and what they are called. The projection folds only
40 // rows carrying both, so a customer key another entity also uses cannot bleed into the members row.
41 const MEMBER_TARGET_ENTITY_TYPE = 'members/member' ;
42 const MEMBER_OPERATION = 'ensure-member' ;
43 const MEMBER_ENTITY = 'members' ;
44
45 // The completion buckets a class can land in (the completion partition plus the unresolved counter).
46 const PROJECTION = Object. freeze ({
47 IMPORTED: 'imported' ,
48 ALREADY_PRESENT: 'alreadyPresentByCrosswalk' ,
49 DEFERRED: 'deferred' ,
50 FAILED: 'failed' ,
51 UNRESOLVED: 'unresolved' ,
52 });
53
54 // A primary outcome splits into a policy CLASS when one outcome code needs two treatments: a
55 // reconciliation by crosswalk versus by login email, and a member-failed that was definitely
56 // rejected, never sent, or of unknown result. Each class has exactly one row.
57 const POLICY = Object. freeze ({
58 written: Object. freeze ({
59 outcome: MEMBER_OUTCOME . WRITTEN ,
60 journalStatus: 'imported' ,
61 failureOutcome: null ,
62 projection: PROJECTION . IMPORTED ,
63 nextAction: null ,
64 selection: null ,
65 telemetry: null ,
66 }),
67 'reconciled-crosswalk' : Object. freeze ({
68 outcome: MEMBER_OUTCOME . RECONCILED_EXISTING ,
69 matchedBy: 'crosswalk' ,
70 journalStatus: 'skipped_already_imported' ,
71 failureOutcome: null ,
72 projection: PROJECTION . ALREADY_PRESENT ,
73 nextAction: null ,
74 selection: null ,
75 telemetry: null ,
76 }),
77 'reconciled-login-email' : Object. freeze ({
78 outcome: MEMBER_OUTCOME . RECONCILED_EXISTING ,
79 matchedBy: 'loginEmail' ,
80 journalStatus: 'imported' ,
81 failureOutcome: null ,
82 projection: PROJECTION . IMPORTED ,
83 reasonLine: 'adopted existing member by login email' ,
84 nextAction: null ,
85 selection: null ,
86 telemetry: null ,
87 }),
88 'no-email' : Object. freeze ({
89 outcome: MEMBER_OUTCOME . NOT_WRITTEN_NO_EMAIL ,
90 journalStatus: 'deferred' ,
91 failureOutcome: null ,
92 projection: PROJECTION . DEFERRED ,
93 nextAction: 'add the email at source, then re-run with --deferred-only' ,
94 selection: 'deferred-only' ,
95 telemetry: Object. freeze ({ event_type: 'fidelity_loss' , subtype: 'no_target' , severity: 'degraded' }),
96 }),
97 ambiguous: Object. freeze ({
98 outcome: MEMBER_OUTCOME . RECONCILIATION_AMBIGUOUS ,
99 journalStatus: 'deferred' ,
100 failureOutcome: null ,
101 projection: PROJECTION . DEFERRED ,
102 nextAction: 'record the chosen member id as the crosswalk row, then re-run with --deferred-only' ,
103 selection: 'deferred-only' ,
104 telemetry: Object. freeze ({ event_type: 'manual_action_required' , subtype: 'other' , severity: 'degraded' }),
105 }),
106 'target-read-failed' : Object. freeze ({
107 outcome: MEMBER_OUTCOME . TARGET_READ_FAILED ,
108 journalStatus: 'failed_retryable' ,
109 failureOutcome: 'failed_retryable' ,
110 projection: PROJECTION . FAILED ,
111 nextAction: 're-run with --failed-only; nothing was written' ,
112 selection: 'failed-only' ,
113 telemetry: Object. freeze ({ event_type: 'error' , subtype: null , severity: 'degraded' }),
114 }),
115 'already-exists-unreconciled' : Object. freeze ({
116 outcome: MEMBER_OUTCOME . ALREADY_EXISTS_UNRECONCILED ,
117 journalStatus: 'succeeded_unverified' ,
118 failureOutcome: 'unresolved' ,
119 projection: PROJECTION . UNRESOLVED ,
120 nextAction: 're-run with --unresolved-only: reads the member back by login email' ,
121 selection: 'unresolved-only' ,
122 telemetry: Object. freeze ({ event_type: 'error' , subtype: null , severity: 'degraded' }),
123 }),
124 'failed-unknown' : Object. freeze ({
125 outcome: MEMBER_OUTCOME . FAILED ,
126 journalStatus: 'succeeded_unverified' ,
127 failureOutcome: 'unresolved' ,
128 projection: PROJECTION . UNRESOLVED ,
129 nextAction: 're-run with --unresolved-only: reads the member back by login email' ,
130 selection: 'unresolved-only' ,
131 telemetry: Object. freeze ({ event_type: 'error' , subtype: null , severity: 'degraded' }),
132 }),
133 // The client refused the create before sending it (requestSent false): nothing reached Wix,
134 // so the outcome is known and a re-send is safe. Never unresolved: that would send a resume
135 // to read back a member that cannot exist.
136 'failed-not-sent' : Object. freeze ({
137 outcome: MEMBER_OUTCOME . FAILED ,
138 journalStatus: 'failed_retryable' ,
139 failureOutcome: 'failed_retryable' ,
140 projection: PROJECTION . FAILED ,
141 nextAction: 're-run with --failed-only; the request was never sent' ,
142 selection: 'failed-only' ,
143 telemetry: Object. freeze ({ event_type: 'error' , subtype: null , severity: 'degraded' }),
144 }),
145 'failed-rejected' : Object. freeze ({
146 outcome: MEMBER_OUTCOME . FAILED ,
147 journalStatus: 'failed_terminal' ,
148 failureOutcome: 'failed_terminal' ,
149 projection: PROJECTION . FAILED ,
150 nextAction: 'fix the source value, then re-run with --failed-only' ,
151 selection: 'failed-only' ,
152 telemetry: Object. freeze ({ event_type: 'error' , subtype: null , severity: 'degraded' }),
153 }),
154 });
155 const POLICY_CLASSES = Object. freeze (Object. keys ( POLICY ));
156
157 // Supplemental finding policy. `droppedField` findings become a dropped-field line and a
158 // fidelity_loss event ONLY on a member that was actually written: no contact was sent otherwise.
159 const FINDING_POLICY = Object. freeze ({
160 [ MEMBER_FINDING . PHONE_NOT_NORMALIZED ]: Object. freeze ({ droppedField: 'contact.phones' }),
161 [ MEMBER_FINDING . PHONE_COLLISION_DROPPED ]: Object. freeze ({ droppedField: 'contact.phones' }),
162 [ MEMBER_FINDING . CREATE_RETRIED ]: Object. freeze ({ droppedField: null }),
163 [ MEMBER_FINDING . CROSSWALK_ROW_STALE ]: Object. freeze ({ droppedField: null }),
164 [ MEMBER_FINDING . COMPANY_WRITTEN_AFTER_CREATE ]: Object. freeze ({ droppedField: null }),
165 // The member was written; its company/jobTitle follow-up did not land: a dropped field.
166 [ MEMBER_FINDING . COMPANY_NOT_WRITTEN ]: Object. freeze ({ droppedField: 'contact.company' }),
167 // Accompanies the no-email outcome: nothing was sent, so no contact field was dropped.
168 [ MEMBER_FINDING . LOGIN_EMAIL_INVALID ]: Object. freeze ({ droppedField: null }),
169 });
170
171 // Pre-write population gate defects. Any one stops the whole pass before its first write.
172 const POPULATION_DEFECT = Object. freeze ({
173 MISSING_SOURCE_KEY: 'missing-source-key' ,
174 DUPLICATE_SOURCE_KEY: 'duplicate-source-key' ,
175 UNKNOWN_SOURCE_KEY: 'unknown-source-key' ,
176 });
177
178 const OPERATOR_ACTION_AMBIGUOUS = Object. freeze ({
179 code: MEMBER_OUTCOME . RECONCILIATION_AMBIGUOUS ,
180 severity: 'medium' ,
181 action: 'For each listed customer, choose which existing Wix member is theirs, record that member id as the customer \' s crosswalk row, then re-run the member pass with --deferred-only.' ,
182 deadlineReason: 'orders for these customers import without a member link until each is resolved' ,
183 });
184
185 function isMemberOutcome ( code ) {
186 return MEMBER_OUTCOMES . includes (code);
187 }
188
189 function isMemberFinding ( code ) {
190 return MEMBER_FINDINGS . includes (code);
191 }
192
193 // A request the client refused before sending (wix-writers createWixClient sets requestSent
194 // false on every pre-send refusal). Its outcome is known: nothing was sent.
195 function memberRequestNotSent ( error ) {
196 return Boolean (error && typeof error === 'object' && error.requestSent === false );
197 }
198
199 // A member-failed whose result nobody knows: the target may or may not hold the member.
200 // Unknown = the client said so, no status arrived (transport), a 5xx survived the retry, or a
201 // 2xx came back without a member id. A readable 4xx is the target's own decision, and a
202 // request that was never sent is known too.
203 function memberFailureOutcomeUnknown ( error ) {
204 if ( ! error || typeof error !== 'object' ) return true ;
205 if ( memberRequestNotSent (error)) return false ;
206 if (error.outcomeKnown === false ) return true ;
207 const status = error.status;
208 if (status === null || status === undefined ) return true ;
209 const n = Number (status);
210 if ( ! Number. isFinite (n)) return true ;
211 return ! (n >= 400 && n < 500 );
212 }
213
214 // The one place a writer result turns into a policy class. Throws on a code the registry does
215 // not know: silently bucketing it is the defect this file exists to stop.
216 function classifyMemberResult ( result ) {
217 const outcome = result && result.outcome;
218 switch (outcome) {
219 case MEMBER_OUTCOME . WRITTEN : return 'written' ;
220 case MEMBER_OUTCOME . RECONCILED_EXISTING :
221 if (result.matchedBy === 'crosswalk' ) return 'reconciled-crosswalk' ;
222 if (result.matchedBy === 'loginEmail' ) return 'reconciled-login-email' ;
223 throw new Error ( `member-reconciled-existing needs matchedBy crosswalk or loginEmail, got ${ JSON . stringify ( result . matchedBy ) }` );
224 case MEMBER_OUTCOME . NOT_WRITTEN_NO_EMAIL : return 'no-email' ;
225 case MEMBER_OUTCOME . RECONCILIATION_AMBIGUOUS : return 'ambiguous' ;
226 case MEMBER_OUTCOME . TARGET_READ_FAILED : return 'target-read-failed' ;
227 case MEMBER_OUTCOME . ALREADY_EXISTS_UNRECONCILED : return 'already-exists-unreconciled' ;
228 case MEMBER_OUTCOME . FAILED :
229 if ( memberRequestNotSent (result.error)) return 'failed-not-sent' ;
230 return memberFailureOutcomeUnknown (result.error) ? 'failed-unknown' : 'failed-rejected' ;
231 default :
232 throw new Error ( `unknown member outcome: ${ JSON . stringify ( outcome ) }` );
233 }
234 }
235
236 // Error-code derivation. Applied to the final error and to the first error of a retry.
237 // Always a non-empty token of at most 48 characters with no whitespace, so it can be a
238 // telemetry error_code as-is. Nothing is parsed out of a message.
239 const ERROR_CODE_MAX = 48 ;
240 function sanitizeCode ( value ) {
241 const token = String (value). trim (). replace ( / \s + / g , '_' ). slice ( 0 , ERROR_CODE_MAX );
242 return token || null ;
243 }
244
245 function deriveMemberErrorCode ( error ) {
246 const e = error && typeof error === 'object' ? error : {};
247 if (e.createReturnedNoId === true ) return 'create-returned-no-id' ;
248 // The refusal's own code (e.g. DEPENDENCY_READINESS_REQUIRED), never 'transport-error':
249 // no transport happened.
250 if (e.requestSent === false ) return (e.code !== undefined && e.code !== null && sanitizeCode (e.code)) || 'request-not-sent' ;
251 if (e.applicationErrorCode !== undefined && e.applicationErrorCode !== null && e.applicationErrorCode !== '' ) {
252 const code = sanitizeCode (e.applicationErrorCode);
253 if (code) return code;
254 }
255 const status = e.status === undefined || e.status === null ? null : Number (e.status);
256 if (status !== null && Number. isFinite (status) && status >= 400 ) return String (status);
257 if (status !== null && Number. isFinite (status) && status >= 200 && status < 300 && e.outcomeKnown === false ) {
258 return 'unparseable-2xx-response' ;
259 }
260 if (status === null || ! Number. isFinite (status)) return 'transport-error' ;
261 // A status below 400 with no other marker: the reply arrived and did not carry a member.
262 return 'create-returned-no-id' ;
263 }
264
265 // Written/skipped tally, the shape the coupon and plan tallies share. Delegated here so the
266 // buckets come from the policy table rather than a hand-kept list of "written" codes.
267 function tallyMemberOutcomes ( outcomes = []) {
268 const written = new Set ([ MEMBER_OUTCOME . WRITTEN , MEMBER_OUTCOME . RECONCILED_EXISTING ]);
269 const skippedBy = {};
270 let writtenCount = 0 ;
271 for ( const code of outcomes) {
272 if ( ! isMemberOutcome (code)) throw new Error ( `unknown member outcome: ${ JSON . stringify ( code ) }` );
273 if (written. has (code)) { writtenCount += 1 ; continue ; }
274 skippedBy[code] = (skippedBy[code] || 0 ) + 1 ;
275 }
276 return { 'members-written' : writtenCount, 'members-skipped' : outcomes. length - writtenCount, skippedBy };
277 }
278
279 module . exports = {
280 MEMBER_OUTCOME,
281 MEMBER_OUTCOMES,
282 MEMBER_FINDING,
283 MEMBER_FINDINGS,
284 MEMBER_TARGET_ENTITY_TYPE,
285 MEMBER_OPERATION,
286 MEMBER_ENTITY,
287 PROJECTION,
288 POLICY,
289 POLICY_CLASSES,
290 FINDING_POLICY,
291 POPULATION_DEFECT,
292 OPERATOR_ACTION_AMBIGUOUS,
293 ERROR_CODE_MAX,
294 isMemberOutcome,
295 isMemberFinding,
296 memberRequestNotSent,
297 memberFailureOutcomeUnknown,
298 classifyMemberResult,
299 deriveMemberErrorCode,
300 tallyMemberOutcomes,
301 };