Setting the file. One moment.
Contact Import · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
lib/contact-import.js
lib/ contact-import.js
JavaScript · 154 lines · 12 KB
require
(
'../../../lib/local-state'
);
7 const executionCapability = require ( '../../../lib/execution-capability' );
8 const sleepDefault = ms => new Promise ( resolve => setTimeout (resolve, ms));
9 const LOOKUP_TIMEOUT_MS = 10000 ;
10
11 // Schema: Contacts V5 Find Matching Contacts (GET); keep every primary identifier.
12 // Marked as a bounded read: an import-task client refuses any unmarked request as a write.
13 async function findMatching ( wix , expected ) {
14 const params = new URLSearchParams ();
15 if (expected.externalId) params. set ( 'externalId' , expected.externalId);
16 if (expected.email?.email) params. set ( 'email' , expected.email.email);
17 if (expected.phone?.phone) params. set ( 'phone' , expected.phone.phone);
18 const result = await wix. send (w. buildBoundedReadRequest ({ method: 'GET' , url: `https://www.wixapis.com/contacts/v5/contacts/find-matching?${ params }` }, LOOKUP_TIMEOUT_MS ));
19 if ( ! Array. isArray (result?.contacts) || result.contacts. length >= 100 ) throw new Error ( 'Contact lookup is malformed or may be truncated' );
20 return result.contacts;
21 }
22 async function importContacts ( wix , { projectDir , siteId , rows , evaluation , verifyAttempts = 3 , sleep = sleepDefault, dryRun = false } = {}) {
23 if ( ! projectDir || ! siteId || ! Array. isArray (rows) || ! Number. isInteger (verifyAttempts) || verifyAttempts < 1 || verifyAttempts > 5 ) throw new Error ( 'Invalid contact import arguments' );
24 if (rows. some ( r => typeof r.sourceKey !== 'string' || ! r.sourceKey) || new Set (rows. map ( r => r.sourceKey)).size !== rows. length ) throw new Error ( 'Unique source keys required' );
25 // Inside an import task every contact write needs the task's readiness grant for its own key.
26 const importTask = executionCapability. clientContext (wix)?.kind === 'import-task' ;
27 if (importTask && ! dryRun && ! evaluation) throw new Error ( 'DEPENDENCY_EVALUATION_REQUIRED' );
28 return local. withStateLock (projectDir, async () => {
29 const state = await stateIO. load (projectDir, siteId);
30 const crosswalk = await local. loadCrosswalk (projectDir);
31 const work = rows. map ( row => {
32 try {
33 v. assertProjection (row.contact, 'contact' );
34 const expected = w. buildCreateContactRequest ({ contact: row.contact }).body.contact;
35 v. assertProjection (expected);
36 const limits = [[expected.externalId, 100 ], [expected.name?.first, 1000 ], [expected.name?.last, 1000 ], [expected.email?.email, 320 ], [expected.phone?.phone, 50 ]];
37 if (limits. some (([ value , max ]) => value != null && ( typeof value !== 'string' || value. length > max))) throw new Error ( 'Contact field exceeds its declared type or limit' );
38 for ( const [ key , max ] of [[ 'additionalEmails' , 10 ], [ 'additionalPhones' , 10 ], [ 'addresses' , 50 ]]) if (expected[key] != null && ( ! Array. isArray (expected[key]) || expected[key]. length > max)) throw new Error ( `Contact ${ key } exceeds its declared array limit` );
39 return { ... row, baseline: crosswalk.bySource[row.sourceKey] || state.rows[row.sourceKey]?.baseline || null , expected, uncertain: Boolean (state.rows[row.sourceKey]?.uncertain), targetId: state.rows[row.sourceKey]?.targetId || crosswalk.bySource[row.sourceKey]?.targetId || state.rows[row.sourceKey]?.baseline?.targetId || null };
40 } catch (e) { return { ... row, baseline: crosswalk.bySource[row.sourceKey] || state.rows[row.sourceKey]?.baseline || null , targetId: state.rows[row.sourceKey]?.targetId || crosswalk.bySource[row.sourceKey]?.targetId || state.rows[row.sourceKey]?.baseline?.targetId, uncertain: state.rows[row.sourceKey]?.uncertain, error: e.message }; }
41 });
42 // Resolve the entire input set before writes: even a later row can conflict with an earlier one.
43 const shared = new Map ();
44 for ( const r of work) for ( const [ key , value ] of Object. entries ({ externalId: r.expected?.externalId, email: v. email (r.expected?.email?.email), phone: r.expected?.phone?.phone })) {
45 if ( ! value) continue ;
46 const k = `${ key }:${ value }` ;
47 if ( ! shared. has (k)) shared. set (k, []);
48 shared. get (k). push (r);
49 }
50 for ( const group of shared. values ()) if (group. length > 1 ) for ( const r of group) r.error = 'Conflicting source contact identifiers' ;
51 if (dryRun) return { siteId, intended: rows. length , status: 'simulated' , verified: 0 , rows: work. map ( r => ({ sourceKey: r.sourceKey, verified: false , outcome: 'simulated' })) };
52 let readinessGrant = null ;
53 if (importTask) {
54 // The gate journals each deferral with its reason; a deferred row is never sent.
55 const batch = await require ( '../../../lib/dependency-readiness' ). gateBatch ({ evaluation, records: work. filter ( r => ! r.error). map ( r => ({ sourceStableKey: r.sourceKey, payload: {} })) });
56 for ( const { sourceStableKey , deferral } of batch.defer) work. find ( r => r.sourceKey === sourceStableKey).error = deferral.reason;
57 readinessGrant = batch.grant;
58 }
59 for ( const r of work) {
60 if (r.error) continue ;
61 try {
62 if ( ! r.expected.externalId && ! v. email (r.expected.email?.email)) throw new Error ( 'Phone-only or name-only identity is unsupported' );
63 const candidates = await findMatching (wix, r.expected);
64 if (candidates. length > 1 || candidates. some ( c => ! v. contactIdentity (r.expected, c, c.id).verified)) throw Object. assign ( new Error ( 'Conflicting target contact identity' ), { identityConflict: true });
65 if (r.targetId) {
66 if ( ! contactId (r.targetId)) throw Object. assign ( new Error ( 'Invalid candidate contact ID' ), { identityConflict: true });
67 const known = await w. getContact (wix, r.targetId);
68 if ( ! v. contactIdentity (r.expected, known, r.targetId).verified) throw Object. assign ( new Error ( 'Crosswalk contact identity does not agree' ), { identityConflict: true });
69 if (candidates. length && candidates[ 0 ].id !== r.targetId) throw Object. assign ( new Error ( 'Crosswalk and matching lookup disagree' ), { identityConflict: true });
70 r.actual = known;
71 } else if (candidates. length ) { r.targetId = candidates[ 0 ].id; r.actual = await w. getContact (wix, r.targetId); }
72 else if (state.rows[r.sourceKey]?.uncertain) throw new Error ( 'Previous write outcome is uncertain; reconcile before retry' );
73 if (r.actual && ! v. verifyContact (r.expected, r.actual, r.targetId).verified) throw new Error ( 'Existing contact values differ; use the revision-protected update and conflict-review flow' );
74 if (r.targetId && crosswalk.rows. some ( c => c.sourceStableKey !== r.sourceKey && c.targetSystem === 'wix' && c.targetId === r.targetId && c.targetEntityType !== 'member' )) throw Object. assign ( new Error ( 'Target contact belongs to another source key' ), { identityConflict: true });
75 } catch (e) { if (e && e.interruption === true ) throw e; r.error = e.message; r.identityConflict = Boolean (e.identityConflict); }
76 }
77 for ( const r of work) if (r.targetId && work. some ( other => other !== r && other.targetId === r.targetId)) { r.error = 'Distinct source keys share a target contact' ; r.identityConflict = true ; }
78 const save = r => {
79 const previous = state.rows[r.sourceKey];
80 const lastVerifiedReceipt = v. validReceipt (previous?.receipt) ? previous.receipt : previous?.lastVerifiedReceipt || null ;
81 state.rows[r.sourceKey] = { sourceKey: r.sourceKey, targetId: r.targetId, baseline: r.baseline, identityConflict: Boolean (r.identityConflict),
82 outcome: r.outcome || 'needs_verification' , uncertain: Boolean (r.uncertain), error: r.error || null , receipt: r.receipt || null , lastVerifiedReceipt };
83 };
84 for ( const r of work) save (r);
85 await stateIO. checkpoint (projectDir, state, work);
86 const pending = work. filter ( r => ! r.error && ! (r.actual && v. verifyContact (r.expected, r.actual, r.targetId).verified));
87 const batches = [];
88 for ( const memberMode of [ false , true ]) {
89 const selected = pending. filter ( r => (r.updateMember === true ) === memberMode);
90 for ( let start = 0 ; start < selected. length ; start += 100 ) batches. push (selected. slice (start, start + 100 ));
91 }
92 for ( const group of batches) {
93 for ( const r of group) { r.uncertain = true ; save (r); }
94 await stateIO. checkpoint (projectDir, state, group);
95 try {
96 const writer = readinessGrant ? { send : request => wix. send (w. withBulkReadinessGrant (request, { readinessGrant, sourceStableKeys: group. map ( r => r.sourceKey) })) } : wix;
97 const result = await w. bulkUpsertContacts (writer, group. map ( r => r.expected), { updateMember: group[ 0 ].updateMember === true });
98 for ( const outcome of result.results) {
99 if ( ! outcome.correlated) continue ;
100 const r = group[outcome.index];
101 if (outcome.success) {
102 if (r.targetId && r.targetId !== outcome.contactId) { r.error = 'Upsert returned a different contact identity' ; continue ; }
103 r.targetId = outcome.contactId; r.uncertain = false ; r.confirmedCreate = true ;
104 } else { r.error = outcome.errorCode || 'Contact write failed' ; r.uncertain = false ; }
105 save (r);
106 }
107 for ( const index of result.unresolvedIndexes) { group[index].error = 'Unresolved bulk contact result' ; save (group[index]); }
108 } catch (e) {
109 // An expired token stops the import, and never as a contact outcome. The client raises it
110 // only for a request that was refused or never sent, so the group is not uncertain:
111 // clear the pre-write mark, or a resume after re-minting refuses every row in it.
112 if (e && e.interruption === true ) {
113 if (e.requestApplied === false ) { for ( const r of group) { r.uncertain = false ; save (r); } await stateIO. checkpoint (projectDir, state, group); }
114 throw e;
115 }
116 for ( const r of group) {
117 r.error = e.message;
118 // Refused before sending, or denied: nothing was written, so nothing is uncertain.
119 if (e.requestSent === false || [ 401 , 403 ]. includes (e.status || e.statusCode)) r.uncertain = false ;
120 save (r);
121 }
122 }
123 await stateIO. checkpoint (projectDir, state, group);
124 }
125 for ( const r of work) if (r.targetId && work. some ( other => other !== r && other.targetId === r.targetId)) { r.error = 'Distinct source keys share a target contact' ; r.identityConflict = true ; }
126 const publications = [];
127 let verifiedBatch = [];
128 for ( const r of work) {
129 if ( ! r.error && r.targetId) for ( let attempt = 1 ; attempt <= verifyAttempts; attempt ++ ) {
130 try {
131 const actual = await w. getContact (wix, r.targetId);
132 if ( ! v. contactIdentity (r.expected, actual, r.targetId).verified) r.identityConflict = true ;
133 r.receipt = v. receipt ({ siteId, sourceKey: r.sourceKey, kind: 'contact' , expected: r.expected, actual, targetId: r.targetId, operation: 'bulkUpsertContacts' , attempts: attempt });
134 if (r.identityConflict) break ;
135 if (r.receipt.verified) { r.outcome = 'verified' ; r.uncertain = false ; break ; }
136 } catch (e) { if (e && e.interruption === true ) throw e; r.readError = e.message; }
137 if (attempt < verifyAttempts) await sleep ( 100 * attempt);
138 }
139 if ( ! r.receipt?.verified) { r.error ||= r.readError || 'Contact values could not be verified' ; r.outcome = 'needs_verification' ; }
140 save (r);
141 if (r.outcome === 'verified' ) publications. push ({ row: {
142 schemaVersion: 1 , sourceSystem: r.sourceSystem || 'source' , sourceEntityType: r.sourceEntityType || 'contact' , sourceId: stateIO. crosswalkSourceId (r),
143 sourceStableKey: r.sourceKey, targetSystem: 'wix' , targetEntityType: 'contact' , targetId: r.targetId, status: 'imported' ,
144 }, receipt: r.receipt, options: r });
145 verifiedBatch. push (r);
146 if (verifiedBatch. length === 100 ) { await stateIO. checkpoint (projectDir, state, verifiedBatch); verifiedBatch = []; }
147 }
148 await stateIO. checkpoint (projectDir, state, verifiedBatch);
149 await stateIO. persist (projectDir, state);
150 await stateIO. publishMany (projectDir, publications, work. filter ( r => r.identityConflict). map ( r => r.sourceKey));
151 return stateIO. report (work. map ( r => state.rows[r.sourceKey]), siteId);
152 });
153 }
154 module . exports = { findMatching, importContacts };