Setting the file. One moment.
Site Regional · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page const destinationSiteId
— line 105
This file
Number 45.146
Position 146 of 182
Type JavaScript
Size 40 KB
Lines 740 lib/ site-regional.js
JavaScript · 740 lines · 40 KB
14 //
15 // The setup step, planner and probe live here; the task dependency check is in `site-regional-gate.js`
16 // and the completion-report rows in `site-regional-report.js`. Both are re-exported from here.
17
18 const crypto = require ( 'node:crypto' );
19 const fs = require ( 'node:fs' );
20 const fsp = require ( 'node:fs/promises' );
21 const path = require ( 'node:path' );
22
23 const sr = require ( './source-regional.js' );
24 const wixAuth = require ( './wix-auth.js' );
25
26 const SETTINGS = Object. freeze ([ 'currency' , 'timeZone' , 'language' , 'locale' ]);
27 const STEP_ID = 'import-site-locale' ;
28 const ENTITY_REF = 'site/locale' ;
29 const BEFORE_FILE = 'setup/site-locale-before.json' ;
30 const DECISION_KEY = 'siteLocaleDecision' ;
31 const ANSWER_EFFECT = 'site-locale-answer' ;
32 const ANSWER_CHOICES = Object. freeze ([ 'change' , 'keep' ]);
33
34 // --- which entities the gate guards -----------------------------------------------------------
35
36 // Explicit target-ref classification. Some domains contain both sensitive and neutral entities,
37 // so family-wide matching would hold categories, tags or RSVPs that carry neither amount nor local
38 // schedule. A task in both sensitive sets runs both checks.
39 const MONEY_BEARING_REFS = Object. freeze ([
40 'bookings/service' , 'bookings/service-add-on' ,
41 'ecom/discount-rule' , 'ecom/order' , 'ecom/order-applied-discount' , 'ecom/order-invoice-document' ,
42 'ecom/order-line-item' , 'ecom/order-payment' , 'ecom/order-refund' , 'ecom/shipping-option' ,
43 'events/ticket-definition' , 'gift-cards/gift-card' , 'gift-cards/gift-card-product' ,
44 'pricing-plans/plan' , 'pricing-plans/plan-order' ,
45 'stores/coupon' , 'stores/product' , 'stores/product-customization' , 'stores/product-variant' , 'stores/promotion' , 'stores/subscription-product' ,
46 ]);
47 const WALL_CLOCK_REFS = Object. freeze ([ 'bookings/booking' , 'bookings/service' , 'events/event' ]);
48 // Families in which every current entity is audited neutral; mixed-family neutral refs are listed
49 // separately. Coverage tests fail for every new target ref until it is classified.
50 const REGIONAL_NEUTRAL_FAMILIES = Object. freeze ([ 'blog' , 'cms' , 'crm' , 'forms' , 'media' , 'members' , 'sales-channels' , 'seo' , 'site' , 'tax' ]);
51 const REGIONAL_NEUTRAL_REFS = Object. freeze ([
52 'ecom/delivery-profile' , 'ecom/order-line-item-options' , 'ecom/order-merchant-note' , 'ecom/pickup-location' , 'ecom/wishlist-item' ,
53 'events/category' , 'events/registration-form' , 'events/rsvp' ,
54 'stores/back-in-stock-request' , 'stores/brand' , 'stores/category' , 'stores/inventory-item' , 'stores/product-bundle' , 'stores/product-tag' , 'stores/review' ,
55 ]);
56
57 const refFamily = ( ref ) => String (ref). split ( '/' )[ 0 ];
58 const isMoneyBearingRef = ( ref ) => MONEY_BEARING_REFS . includes (ref);
59 const isWallClockRef = ( ref ) => WALL_CLOCK_REFS . includes (ref);
60 const isRegionalNeutralRef = ( ref ) => REGIONAL_NEUTRAL_FAMILIES . includes ( refFamily (ref)) || REGIONAL_NEUTRAL_REFS . includes (ref);
61 const classifiedRef = ( ref ) => isMoneyBearingRef (ref) || isWallClockRef (ref) || isRegionalNeutralRef (ref);
62
63 function partitionRefs ( entityRefs ) {
64 const refs = [ ...new Set ((entityRefs || []). filter (( ref ) => typeof ref === 'string' ))]. sort ();
65 return {
66 moneyBearingRefs: refs. filter (isMoneyBearingRef),
67 wallClockRefs: refs. filter (isWallClockRef),
68 neutralRefs: refs. filter (isRegionalNeutralRef),
69 unclassifiedRefs: refs. filter (( ref ) => ! classifiedRef (ref)),
70 };
71 }
72
73 // --- small file helpers ---------------------------------------------------------------------------
74
75 const sha256 = ( bytes ) => crypto. createHash ( 'sha256' ). update (bytes). digest ( 'hex' );
76
77 function readJsonSync ( file , fallback = null ) {
78 try { return JSON . parse (fs. readFileSync (file, 'utf8' )); } catch (error) {
79 if (error.code === 'ENOENT' ) return fallback;
80 throw error;
81 }
82 }
83
84 async function writeJsonAtomic ( file , value ) {
85 await fsp. mkdir (path. dirname (file), { recursive: true });
86 const tmp = `${ file }.${ process . pid }.${ Date . now () }.tmp` ;
87 await fsp. writeFile (tmp, `${ JSON . stringify ( value , null , 2 ) } \n ` );
88 await fsp. rename (tmp, file);
89 }
90
91 function fileSha256 ( projectDir , relative ) {
92 try { return sha256 (fs. readFileSync (path. join (projectDir, relative))); } catch (error) {
93 if (error.code === 'ENOENT' ) return null ;
94 throw error;
95 }
96 }
97
98 function envValue ( projectDir , key ) {
99 let raw;
100 try { raw = fs. readFileSync (path. join (projectDir, 'config' , 'wix.env' ), 'utf8' ); } catch { return null ; }
101 const match = raw. split ( / \r ? \n / ). map (( line ) => line. match ( / ^ ( [A-Z0-9_] + )=( . * ) $ / )). filter (Boolean). find (( m ) => m[ 1 ] === key);
102 return match && match[ 2 ]. trim () ? match[ 2 ]. trim () : null ;
103 }
104
105 const destinationSiteId = ( projectDir ) => envValue (projectDir, 'WIX_SITE_ID' );
106
107 function setupPlan ( projectDir ) { return readJsonSync (path. join (projectDir, 'setup' , 'setup-plan.json' )); }
108
109 function priorRegionalReceipt ( projectDir ) {
110 const verification = readJsonSync (path. join (projectDir, 'setup' , 'setup-verification.json' ));
111 return (verification?.requirements || []). find (( item ) => item?.id === STEP_ID )?.regional || null ;
112 }
113
114 function declaredRequirements ( projectDir ) {
115 const declared = readJsonSync (path. join (projectDir, 'setup' , 'setup-requirements.json' ));
116 return Array. isArray (declared) ? declared : (declared && declared.requirements) || [];
117 }
118
119 // The pairing predicate: the scheduling guard and the dispatcher share it, so they can never
120 // disagree about which steps count (the same rule as extended-field provisioning).
121 function stepConfiguresSiteRegional ( step , declaredList ) {
122 if ( ! step) return false ;
123 if (step.id === STEP_ID ) return true ;
124 if (step.kind === 'siteLocale' ) return true ;
125 return (declaredList || []). some (( item ) => item && item.kind === 'siteLocale' && (item.setupStepId === step.id || item.id === step.id));
126 }
127
128 function planHasRegionalStep ( projectDir ) {
129 const plan = setupPlan (projectDir);
130 const declared = declaredRequirements (projectDir);
131 return Boolean (plan && Array. isArray (plan.steps) && plan.steps. some (( step ) => stepConfiguresSiteRegional (step, declared)));
132 }
133
134 // `created` only when the destination receipt says this run created exactly this site; anything
135 // else -- no receipt, another site, an unverified receipt -- is an existing site. Never inferred
136 // from the site strategy alone.
137 function siteOriginFor ( projectDir , siteId ) {
138 const receipt = readJsonSync (path. join (projectDir, 'orchestration' , 'destination.json' ));
139 return receipt && receipt.status === 'verified' && receipt.strategy === 'new' && siteId && receipt.metaSiteId === siteId ? 'created' : 'existing' ;
140 }
141
142 // The requirement `rp-setup-discovery` (and the quick setup generator) emit next to the step.
143 // `siteOrigin` here is informational; the executor recomputes it from the receipt at run time.
144 function buildRegionalRequirement ({ projectDir , entityRefs }) {
145 const refs = [ ...new Set ((entityRefs || []). filter (( ref ) => typeof ref === 'string' ))]. sort ();
146 const { moneyBearingRefs , wallClockRefs , neutralRefs , unclassifiedRefs } = partitionRefs (refs);
147 return {
148 id: STEP_ID ,
149 kind: 'siteLocale' ,
150 entityRefs: [ ENTITY_REF ],
151 source: sr. SOURCE_REGIONAL_FILE ,
152 siteOrigin: siteOriginFor (projectDir, destinationSiteId (projectDir)),
153 severity: 'blocker' ,
154 automation: 'automatable' ,
155 dependentEntityRefs: refs,
156 moneyBearingRefs,
157 wallClockRefs,
158 neutralRefs,
159 unclassifiedRefs,
160 };
161 }
162
163 function buildRegionalStep () {
164 return { id: STEP_ID , kind: 'siteLocale' , entityRefs: [ ENTITY_REF ] };
165 }
166
167 // The entity refs a project will write, for the two kinds of plan: a quick run's adapter contract,
168 // or the approved mapping plan's targets. Conservative on purpose: a ref listed is a ref gated.
169 function entityRefsForProject ( projectDir ) {
170 const refs = new Set ();
171 const quick = readJsonSync (path. join (projectDir, 'quick-mode' , 'plan.json' ));
172 if (quick && Array. isArray (quick.entities)) {
173 const contractFile = { 'quick-woocommerce' : '../resources/rp-quick-woocommerce/quick-mode.json' , 'quick-shopify' : '../resources/rp-quick-shopify/quick-mode.json' }[quick.adapter];
174 const contract = contractFile ? readJsonSync (path. resolve (__dirname, contractFile)) : null ;
175 const wanted = new Set (quick.entities. map (( entity ) => entity.id));
176 for ( const entity of (contract && contract.entities) || []) {
177 if ( ! wanted. has (entity.id)) continue ;
178 for ( const ref of [entity.target, ... (entity.auxiliaryTargets || [])]) if (ref) refs. add (ref);
179 }
180 return [ ... refs]. sort ();
181 }
182 const mapping = readJsonSync (path. join (projectDir, 'mapping' , 'mapping-plan.json' ));
183 for ( const row of require ( './website-handoff.js' ). readEntityMappings (mapping)) {
184 for ( const ref of [row.targetRef, ... (Array. isArray (row.targetRefs) ? row.targetRefs : [])]) if ( typeof ref === 'string' && ref. includes ( '/' )) refs. add (ref);
185 }
186 return [ ... refs]. sort ();
187 }
188
189 // Writes the requirement/step pair after notification safety and before every install/provisioning
190 // step. Idempotent: a rerun replaces the pair.
191 async function emitRegionalSetup ( projectDir , { entityRefs = null , order = null } = {}) {
192 const planFile = path. join (projectDir, 'setup' , 'setup-plan.json' );
193 const reqFile = path. join (projectDir, 'setup' , 'setup-requirements.json' );
194 const plan = readJsonSync (planFile);
195 const declared = readJsonSync (reqFile);
196 if ( ! plan || ! Array. isArray (plan.steps) || ! declared) return { emitted: false , reason: 'setup-artifacts-missing' };
197 const isArray = Array. isArray (declared);
198 const list = (isArray ? declared : declared.requirements || []). filter (( item ) => ! (item && item.id === STEP_ID ));
199 const steps = plan.steps. filter (( step ) => ! (step && step.id === STEP_ID ));
200 const requirement = buildRegionalRequirement ({ projectDir, entityRefs: entityRefs || entityRefsForProject (projectDir) });
201 const withOrder = order === null ? {} : { order };
202 const insertAfterMute = ( rows , value ) => {
203 const index = rows. findIndex (( row ) => row && row.id === 'mute-site-notifications' );
204 rows. splice (index < 0 ? 0 : index + 1 , 0 , value);
205 };
206 insertAfterMute (steps, { ... buildRegionalStep (), action: STEP_ID , ... withOrder });
207 insertAfterMute (list, { ... requirement, ... withOrder });
208 await writeJsonAtomic (planFile, { ... plan, steps });
209 await writeJsonAtomic (reqFile, isArray ? list : { ... declared, requirements: list });
210 return { emitted: true , requirement };
211 }
212
213 // --- the owner's single approval ------------------------------------------------------------------
214
215 function readDecisions ( projectDir ) { return readJsonSync (path. join (projectDir, 'orchestration' , 'decisions.json' ), {}) || {}; }
216
217 const canonical = ( value ) => JSON . stringify (value, ( key , v ) => (v && typeof v === 'object' && ! Array. isArray (v)
218 ? Object. fromEntries (Object. keys (v). sort (). map (( k ) => [k, v[k]])) : v));
219
220 // Identifies exactly which changes were put to the owner: field, source value and pre-change site value.
221 function changesDigestOf ( rows ) {
222 return sha256 ( canonical (rows. map (( row ) => [row.field, row.source, row.before ?? row.current])));
223 }
224
225 // SHA-256 of the owner's approval for this site, or of "none". A new answer or another destination moves it,
226 // which makes the setup receipt stale so the step re-runs.
227 function approvalDigest ( projectDir , siteId = destinationSiteId (projectDir)) {
228 const entry = readDecisions (projectDir)[ DECISION_KEY ];
229 return sha256 ( canonical ({ approval: entry && typeof entry === 'object' ? entry : null , siteId: siteId || null }));
230 }
231
232 // An answer survives partial apply: every still-differing row must be one of the immutable rows
233 // shown to the owner with the same source and same pre-change site value. Matching fields need no row.
234 function approvalIsValid ( approval , { siteId , rows = [], changesDigest = null }) {
235 if ( ! (approval && typeof approval === 'object' && ANSWER_CHOICES . includes (approval.value) && approval.siteId === siteId)) return false ;
236 if (Array. isArray (approval.rows)) {
237 return rows. every (( row ) => approval.rows. some (( saved ) => saved && saved.field === row.field
238 && saved.source === row.source && saved.before === row.current));
239 }
240 return Boolean (changesDigest && approval.changesDigest === changesDigest);
241 }
242
243 // --- the pure per-setting plan ---------------------------------------------------------------------------
244
245 function currentValueOf ( properties , field ) {
246 if ( ! properties) return null ;
247 switch (field) {
248 case 'currency' : return properties.paymentCurrency || null ;
249 case 'timeZone' : return properties.timeZone || null ;
250 case 'language' : return properties.language || null ;
251 case 'locale' : return properties.locale && properties.locale.languageCode && properties.locale.country
252 ? `${ properties . locale . languageCode }-${ properties . locale . country }` : null ;
253 default : return null ;
254 }
255 }
256
257 const UNKNOWN_CODES = Object. freeze ({ currency: 'SOURCE_CURRENCY_UNKNOWN' , timeZone: 'SOURCE_TIME_ZONE_UNKNOWN' });
258 const KEEP_GATED_FIELDS = new Set ([ 'currency' , 'timeZone' ]);
259
260 // One row per setting; `action` is match | apply | skip-unknown | needs-approval | kept.
261 function planSiteRegional ({ source , current , siteId = null , siteOrigin , siteState = null , approval = null , moneyBearingRefs = [], wallClockRefs = [] }) {
262 const rows = SETTINGS . map (( field ) => {
263 const gatedRefs = field === 'currency' ? [ ... moneyBearingRefs] : field === 'timeZone' ? [ ... wallClockRefs] : [];
264 return { field, source: sr. settingValue (source, field), current: currentValueOf (current, field), action: null , gatedRefs };
265 });
266 const differing = rows. filter (( row ) => row.source !== null && ! sr. sameValue (row.field, row.source, row.current));
267 const needsApproval = siteOrigin === 'existing' && siteState !== 'existing-untouched' && differing. length > 0 ;
268 const changesDigest = changesDigestOf (differing);
269 const approved = approvalIsValid (approval, { siteId, rows: differing, changesDigest }) ? approval.value : null ;
270 for ( const row of rows) {
271 if (row.source === null ) {
272 row.action = 'skip-unknown' ;
273 row.reason = sr. unknownReason (source, row.field) || 'not-exposed' ;
274 if (row.gatedRefs. length && UNKNOWN_CODES [row.field]) row.code = UNKNOWN_CODES [row.field];
275 } else if ( ! differing. includes (row)) {
276 row.action = 'match' ;
277 } else if ( ! needsApproval || approved === 'change' ) {
278 row.action = 'apply' ;
279 if (approved) row.decisionRef = approval.requestId || null ;
280 } else if (approved === 'keep' ) {
281 row.decisionRef = approval.requestId || null ;
282 row.action = 'kept' ;
283 if ( KEEP_GATED_FIELDS . has (row.field)) row.code = 'SITE_LOCALE_KEPT' ;
284 } else {
285 row.action = 'needs-approval' ;
286 }
287 }
288 return { rows, needsApproval, changesDigest, approved };
289 }
290
291 const pendingApproval = ( rows ) => rows. filter (( row ) => row.action === 'needs-approval' );
292
293 // --- is an existing site untouched? ---------------------------------------------------------------
294
295 const APP_IDS = Object. freeze ({
296 stores: '215238eb-22a5-4c36-9e7b-e7c08025e04e' ,
297 ecom: '1380b703-ce81-ff05-f115-39571d94dfcd' ,
298 });
299
300 const CATALOG_PAGE_LIMIT = 10 ; // pages of 100 -> 1,000 products
301
302 // The approved existing-site boundary is only orders or products. Installed-apps is the source
303 // of truth for a missing app; unreadable app state is unknown and asks.
304 const SITE_USE_FAMILIES = Object. freeze ([
305 { id: 'orders' , appDefId: APP_IDS .ecom, probe: 'orders' },
306 { id: 'catalog' , appDefId: APP_IDS .stores, probe: 'catalog' },
307 ]);
308
309 // `ignoreProductIds`: sample products this toolkit's own Stores install added. They are
310 // not merchant data, so a seeded item a delete missed never makes the site read as in use.
311 async function probeSiteUse ({ wix , writers , ignoreProductIds = new Set () }) {
312 const families = [];
313 let installed;
314 try { installed = await writers. getInstalledWixApps (wix); } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; return { state: 'existing-unknown' , families: [{ id: 'apps' , status: 'unreadable' }] }; }
315 const installedIds = new Set ((installed || []). map (( app ) => app && app.appDefId));
316 for ( const family of SITE_USE_FAMILIES ) {
317 if ( ! installedIds. has (family.appDefId)) { families. push ({ id: family.id, status: 'not-installed' }); continue ; }
318 try {
319 if (family.probe === 'orders' ) {
320 const orders = await writers. queryOrders (wix, { paging: { limit: 1 } });
321 families. push ({ id: family.id, status: orders. length === 0 ? 'empty' : 'in-use' });
322 } else {
323 families. push ({ id: family.id, status: await probeCatalog ({ wix, writers, ignoreProductIds }) });
324 }
325 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; families. push ({ id: family.id, status: 'unreadable' }); }
326 }
327 const statuses = families. map (( family ) => family.status);
328 const state = statuses. includes ( 'in-use' ) ? 'existing-in-use'
329 : statuses. some (( status ) => status === 'unreadable' || status === 'unknown' ) ? 'existing-unknown'
330 : 'existing-untouched' ;
331 return { state, families };
332 }
333
334 async function probeCatalog ({ wix , writers , ignoreProductIds = new Set () }) {
335 let cursor = null ;
336 for ( let page = 0 ; page < CATALOG_PAGE_LIMIT ; page += 1 ) {
337 const response = await wix. send (writers. buildQueryStoresProductsRequest ({ cursorPaging: { limit: 100 , ... (cursor ? { cursor } : {}) } }));
338 const products = ((response && response.products) || []). filter (( product ) => ! (product && ignoreProductIds. has (product.id)));
339 if (products. length ) return 'in-use' ;
340 const next = response && response.pagingMetadata && response.pagingMetadata.cursors && response.pagingMetadata.cursors.next;
341 if ( ! (response && response.pagingMetadata && response.pagingMetadata.hasNext) || ! next) return 'empty' ;
342 cursor = next;
343 }
344 return 'unknown' ; // more than the page limit: never reported untouched
345 }
346
347 // --- plain language ---------------------------------------------------------------------------------------
348
349 function displayName ( type , code ) {
350 try { return new Intl. DisplayNames ( 'en' , { type }). of (code) || code; } catch { return code; }
351 }
352
353 function describeValue ( field , value ) {
354 if (value === null || value === undefined ) return 'not set' ;
355 if (field === 'currency' ) return `${ displayName ( 'currency' , value ) } (${ value })` ;
356 if (field === 'language' ) return `${ displayName ( 'language' , value ) } (${ value })` ;
357 if (field === 'locale' ) {
358 const parsed = sr. parseLocale (value);
359 return parsed ? `${ displayName ( 'language' , parsed . languageCode ) }, ${ displayName ( 'region' , parsed . country ) } (${ value })` : String (value);
360 }
361 return String (value);
362 }
363
364 const FIELD_LABEL = Object. freeze ({ currency: 'Currency' , timeZone: 'Time zone' , language: 'Language' , locale: 'Locale' });
365
366 // The execution-plan section. Reads the source file only: the site itself is read when setup runs.
367 function renderRegionalPreview ( source ) {
368 const lines = [ 'Site settings, set first from the source store' ];
369 for ( const field of SETTINGS ) {
370 const value = sr. settingValue (source, field);
371 lines. push (value === null
372 ? `${ FIELD_LABEL [ field ] }: the source store's value could not be read (${ sr . unknownReason ( source , field ) || 'not-exposed'}); left unchanged`
373 : `${ FIELD_LABEL [ field ] }: set to ${ describeValue ( field , value ) } (from the store)` );
374 }
375 lines. push ( 'A site created by this migration is set directly. An existing site with no orders and no products is set directly too; any order or product asks you first.' );
376 return lines. join ( ' \n ' );
377 }
378
379 async function previewRegionalPlan ( projectDir ) {
380 const requirement = declaredRequirements (projectDir). find (( item ) => item && item.kind === 'siteLocale' );
381 if ( ! requirement) return { applicable: false , reason: 'no-regional-step' };
382 let source;
383 try { source = await sr. readSourceRegional (projectDir); } catch (error) { return { applicable: true , ok: false , code: 'INVALID_SOURCE_REGIONAL' , detail: error.message }; }
384 if ( ! source) return { applicable: true , ok: false , code: 'INVALID_SOURCE_REGIONAL' , detail: 'discovery/source-locale.json is missing' };
385 return { applicable: true , ok: true , section: renderRegionalPreview (source) };
386 }
387
388 // --- the one durable question ---------------------------------------------------------------------------------
389
390 function questionText ( rows , siteState ) {
391 const pairs = rows. map (( row ) => `${ FIELD_LABEL [ row . field ]. toLowerCase () }: the store uses ${ describeValue ( row . field , row . source ) }, your site has ${ describeValue ( row . field , row . current ) }` ). join ( '; ' );
392 const holds = siteState === 'existing-in-use' ? 'Your site already holds at least one order or product.' : 'We could not prove that your site has no orders and no products.' ;
393 const risk = rows. some (( row ) => row.field === 'currency' ) ? ' Changing the currency does not convert anything: existing prices and orders would be read in the new currency.' : '' ;
394 const hours = rows. some (( row ) => row.field === 'timeZone' ) ? ' Changing the time zone changes the hour that bookings and events show.' : '' ;
395 const kept = ' Keeping the site \' s settings means money-bearing data (and bookings and events, when the time zone is kept) is not imported in this migration; everything else still is.' ;
396 return `${ pairs }. ${ holds }${ risk }${ hours }${ kept }` ;
397 }
398
399 function questionInput ( rows , { siteId , siteState , changesDigest }) {
400 const changes = rows. map (( row ) => ({ field: row.field, source: row.source, before: row.current }));
401 return {
402 kind: 'question' ,
403 stage: 'setup' ,
404 title: 'Change the site \' s currency, time zone and language to match the store?' ,
405 prompt: questionText (rows, siteState),
406 response: { mode: 'choice' , required: true },
407 choices: [
408 { id: 'change' , label: 'Change the site \' s settings to the store \' s' },
409 { id: 'keep' , label: 'Keep the site \' s settings' },
410 ],
411 effect: { kind: ANSWER_EFFECT , siteId, changesDigest },
412 subject: { type: 'site-locale' , siteId, changesDigest, siteState, changes },
413 };
414 }
415
416 async function listRegionalRequests ( projectDir , status = 'pending' ) {
417 const ua = require ( './user-actions.js' );
418 const rows = await ua. listUserActions (projectDir, { status });
419 const out = [];
420 for ( const row of rows) {
421 const request = await ua. readUserAction (projectDir, row.requestId);
422 if (request.effect && request.effect.kind === ANSWER_EFFECT ) out. push (request);
423 }
424 return out;
425 }
426
427 // Exactly one open request per set of changes on a site; a request about other changes is superseded.
428 async function raiseApprovalRequest ( projectDir , rows , { siteId , siteState , changesDigest }) {
429 const ua = require ( './user-actions.js' );
430 const open = await listRegionalRequests (projectDir);
431 const match = open. find (( request ) => request.subject && request.subject.siteId === siteId && request.subject.changesDigest === changesDigest);
432 for ( const request of open) if (request !== match) await ua. supersedeUserAction (projectDir, request.requestId);
433 if (match) return match;
434 return ua. createUserAction (projectDir, questionInput ( pendingApproval (rows), { siteId, siteState, changesDigest }));
435 }
436
437 // Re-open the immutable rows from the current keep decision so the owner can replace it with
438 // change. This is an explicit operator action; unattended and one-click flows never call it.
439 async function reopenRegionalRequest ( projectDir ) {
440 const decision = readDecisions (projectDir)[ DECISION_KEY ];
441 if ( ! decision || decision.value !== 'keep' || ! Array. isArray (decision.rows) || ! decision.rows. length ) {
442 throw new Error ( 'site/locale has no keep decision to reconsider' );
443 }
444 if (decision.siteId !== destinationSiteId (projectDir)) throw new Error ( 'site-locale decision names another destination' );
445 const rows = decision.rows. map (( row ) => ({ field: row.field, source: row.source, current: row.before, action: 'needs-approval' }));
446 return raiseApprovalRequest (projectDir, rows, { siteId: decision.siteId, siteState: 'existing-in-use' , changesDigest: decision.changesDigest });
447 }
448
449 // The one writer of the site-locale decision; both CLI entry points reach it through the effect dispatcher.
450 async function recordSiteRegionalApproval ( projectDir , request , answer ) {
451 const effect = request.effect;
452 if ( ! effect || effect.kind !== ANSWER_EFFECT ) throw new Error ( 'not a site-locale answer' );
453 if ( ! ANSWER_CHOICES . includes (answer.choice)) throw new Error ( 'site-locale answer must be change or keep' );
454 if (effect.siteId !== destinationSiteId (projectDir)) throw new Error ( 'site-locale request names another destination; it is superseded' );
455 const { loadArtifacts , writeJsonAtomic : writeOrchestrationJson } = require ( './orchestration-state.js' );
456 const artifacts = await loadArtifacts (projectDir);
457 const rows = ((request.subject && request.subject.changes) || []). map (( row ) => ({ field: row.field, source: row.source, before: row.before }));
458 if ( ! rows. length || rows. some (( row ) => ! SETTINGS . includes (row.field) || row.source == null || row.before == null )) throw new Error ( 'site-locale request has no valid immutable rows' );
459 artifacts.decisions[ DECISION_KEY ] = {
460 value: answer.choice,
461 decidedAt: new Date (). toISOString (),
462 source: 'user' ,
463 rationale: request.title,
464 siteId: effect.siteId,
465 changesDigest: effect.changesDigest,
466 rows,
467 requestId: request.requestId,
468 };
469 await writeOrchestrationJson (path. join (projectDir, 'orchestration' , 'decisions.json' ), artifacts.decisions);
470 }
471
472 // --- the setup step ---------------------------------------------------------------------------------------------
473
474 const REGIONAL_BLOCKERS = Object. freeze ({
475 INVALID_SOURCE_REGIONAL: 'discovery/source-locale.json is invalid. Re-run source discovery and promote, then re-run setup.' ,
476 SOURCE_LOCALE_REQUIRED: 'The source locale is incomplete. Supply every missing value, promote discovery and re-run setup.' ,
477 SITE_PROPERTIES_UNREADABLE: 'The site \' s settings could not be read, so nothing was planned or written. Re-run setup once the site answers.' ,
478 SITE_REGIONAL_APPROVAL_PENDING: 'The site already holds orders or real products, so changing its settings needs the owner \' s approval. Answer the question (change or keep), then re-run setup.' ,
479 SITE_REGIONAL_READBACK_MISMATCH: 'The site did not keep the value that was sent (or could not be read back). Check the site \' s settings in the dashboard, then re-run setup.' ,
480 SITE_REGIONAL_WRITE_REJECTED: 'Wix refused the currency or time zone update. Check the value against the store, then re-run setup.' ,
481 });
482
483 function regionalBlocker ( code , detail ) {
484 return Object. assign ( new Error (detail || code), { blockerCode: code, recommendedAction: REGIONAL_BLOCKERS [code] || 'Investigate, then re-run setup.' });
485 }
486
487 // Applied rows, in this order: one business-region update, language, locale. Each mask is built from the keys sent.
488 function writesFor ( rows ) {
489 const applied = rows. filter (( row ) => row.action === 'apply' );
490 const find = ( field ) => applied. find (( row ) => row.field === field);
491 const region = {};
492 if ( find ( 'currency' )) region.paymentCurrency = find ( 'currency' ).source;
493 if ( find ( 'timeZone' )) region.timeZone = find ( 'timeZone' ).source;
494 const writes = [];
495 if (Object. keys (region). length ) writes. push ({ kind: 'region' , payload: region, fields: [ 'currency' , 'timeZone' ]. filter (find) });
496 if ( find ( 'language' )) writes. push ({ kind: 'language' , payload: find ( 'language' ).source, fields: [ 'language' ] });
497 if ( find ( 'locale' )) writes. push ({ kind: 'locale' , payload: sr. parseLocale ( find ( 'locale' ).source), fields: [ 'locale' ] });
498 return writes;
499 }
500
501 // The dry-run intent: the ordered list a live run would reduce to, from the source file alone.
502 function regionalDryRunIntent ( source ) {
503 const region = {};
504 const currency = sr. settingValue (source, 'currency' );
505 const timeZone = sr. settingValue (source, 'timeZone' );
506 if (currency) region.paymentCurrency = currency;
507 if (timeZone) region.timeZone = timeZone;
508 const steps = [{ op: 'get-properties' }];
509 if (Object. keys (region). length ) steps. push ({ op: 'update-business-region' , paths: Object. keys (region), values: region });
510 if (sr. settingValue (source, 'language' )) steps. push ({ op: 'update-language' , value: sr. settingValue (source, 'language' ) });
511 if (sr. settingValue (source, 'locale' )) steps. push ({ op: 'update-locale' , value: sr. settingValue (source, 'locale' ) });
512 steps. push ({ op: 'get-properties' });
513 return steps;
514 }
515
516 async function readBefore ( projectDir , siteId ) {
517 const file = readJsonSync (path. join (projectDir, BEFORE_FILE ));
518 return file && file.siteId === siteId ? file : null ;
519 }
520
521 async function ensurePaymentMethodsAction ( projectDir , { siteId , before , after }) {
522 if ( ! before || ! after || before === after) return null ;
523 const ua = require ( './user-actions.js' );
524 const subject = { type: 'site-locale-payment-methods-confirmation' , siteId, before, after };
525 for ( const row of await ua. listUserActions (projectDir)) {
526 const request = await ua. readUserAction (projectDir, row.requestId);
527 if (request.subject && canonical (request.subject) === canonical (subject)) return request;
528 }
529 return ua. createUserAction (projectDir, {
530 kind: 'manual_action' ,
531 stage: 'cutover' ,
532 title: `Confirm connected payment methods accept ${ after }` ,
533 prompt: `Before checkout is enabled, confirm in Business Manager that every connected payment method accepts ${ after }.` ,
534 response: { mode: 'none' , required: true },
535 subject,
536 });
537 }
538
539 // A time zone discovery inferred (no named zone at the source) is applied without asking and
540 // confirmed at cutover: one durable task per site + value. Never asked mid-run.
541 const TIME_ZONE_CONFIRMATION = 'site-locale-time-zone-confirmation' ;
542 async function ensureTimeZoneAction ( projectDir , { siteId , row , siteValue }) {
543 const ua = require ( './user-actions.js' );
544 const inference = row.inference || {};
545 const value = row.state === 'unknown' ? (siteValue || 'the site default' ) : row.value;
546 const subject = { type: TIME_ZONE_CONFIRMATION , siteId, value, basis: inference.basis || null , gmtOffset: inference.gmtOffset ?? null };
547 for ( const listed of await ua. listUserActions (projectDir)) {
548 const request = await ua. readUserAction (projectDir, listed.requestId);
549 if (request.subject && canonical (request.subject) === canonical (subject)) return request;
550 }
551 return ua. createUserAction (projectDir, {
552 kind: 'manual_action' ,
553 stage: 'cutover' ,
554 title: `Confirm the site time zone (${ value })` ,
555 prompt: row.state === 'unknown'
556 ? `The source store had no named time zone and none could be picked (${ row . evidence }). The site kept its own zone, ${ value }. Confirm it in Business Manager > Settings > Language & region, or change it there.`
557 : `The source store had no named time zone (${ row . evidence }). The migration set the site to ${ value }. Confirm it in Business Manager > Settings > Language & region, or change it there.` ,
558 response: { mode: 'none' , required: true },
559 subject,
560 });
561 }
562
563 // The setup step. `wix` is the capability-bound setup client; `writers` is the shared writers module
564 // (injected so a test never touches Wix). Returns the `regional` receipt object, or throws an error carrying
565 // `blockerCode` / `recommendedAction` for execute-setup to record.
566 async function executeRegionalStep ({ projectDir , wix , writers , siteId , requirement = {} }) {
567 const priorRegional = priorRegionalReceipt (projectDir);
568 let source;
569 try { source = await sr. readSourceRegional (projectDir); } catch (error) { throw regionalBlocker ( 'INVALID_SOURCE_REGIONAL' , error.message); }
570 if ( ! source) throw regionalBlocker ( 'INVALID_SOURCE_REGIONAL' , 'discovery/source-locale.json is missing' );
571 // An unresolved fixed-offset time zone keeps the site's zone and is confirmed at cutover; never asked.
572 const missing = SETTINGS . filter (( field ) => sr. settingValue (source, field) === null && ! (field === 'timeZone' && sr. keepsSiteTimeZone (source)));
573 if (missing. length ) throw regionalBlocker ( 'SOURCE_LOCALE_REQUIRED' , `source locale requires supplied values for: ${ missing . join ( ', ' ) }` );
574
575 let properties;
576 try { properties = await writers. getSiteProperties (wix); } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; throw regionalBlocker ( 'SITE_PROPERTIES_UNREADABLE' , `site properties could not be read: ${ error . message }` ); }
577 if ( ! properties) throw regionalBlocker ( 'SITE_PROPERTIES_UNREADABLE' , 'site properties read returned nothing' );
578
579 const siteOrigin = siteOriginFor (projectDir, siteId);
580 const moneyBearingRefs = requirement.moneyBearingRefs || [];
581 const wallClockRefs = requirement.wallClockRefs || [];
582 const siteState = siteOrigin === 'created' ? 'created' : ( await probeSiteUse ({ wix, writers, ignoreProductIds: require ( './catalog-baseline' ). installSeededIds (projectDir) })).state;
583 const decision = readDecisions (projectDir)[ DECISION_KEY ] || null ;
584 const planned = planSiteRegional ({
585 source, current: properties, siteId, siteOrigin, siteState, approval: decision,
586 moneyBearingRefs, wallClockRefs,
587 });
588 const rows = planned.rows;
589
590 if ( pendingApproval (rows). length ) {
591 await raiseApprovalRequest (projectDir, rows, { siteId, siteState, changesDigest: planned.changesDigest });
592 throw regionalBlocker ( 'SITE_REGIONAL_APPROVAL_PENDING' , `${ pendingApproval ( rows ). map (( row ) => row . field ). join ( ', ' ) } waiting for the owner's approval` );
593 }
594 const writes = writesFor (rows);
595 // Remember each field immediately before this migration first attempts to write it. A later run
596 // can add a newly differing field, so extend the artifact without changing values for fields an
597 // earlier attempt already recorded.
598 if (writes. length ) {
599 const priorBefore = await readBefore (projectDir, siteId);
600 const attemptedFields = [ ...new Set (writes. flatMap (( write ) => write.fields))];
601 const newlyAttempted = attemptedFields. filter (( field ) => ! (priorBefore?.attemptedFields || []). includes (field));
602 const values = { ... (priorBefore?.values || {}) };
603 for ( const field of newlyAttempted) values[field] = currentValueOf (properties, field);
604 await writeJsonAtomic (path. join (projectDir, BEFORE_FILE ), {
605 schemaVersion: 1 ,
606 siteId,
607 at: priorBefore?.at || new Date (). toISOString (),
608 sourceDigest: priorBefore?.sourceDigest || fileSha256 (projectDir, sr. SOURCE_REGIONAL_FILE ),
609 attemptedFields: [ ...new Set ([ ... (priorBefore?.attemptedFields || []), ... attemptedFields])],
610 values,
611 });
612 }
613
614 for ( const write of writes) {
615 try {
616 if (write.kind === 'region' ) await writers. updateBusinessRegion (wix, write.payload);
617 else if (write.kind === 'language' ) await writers. updateSiteLanguage (wix, write.payload);
618 else await writers. updateSiteLocale (wix, write.payload);
619 } catch (error) {
620 if (wixAuth. isRunInterruption (error)) throw error;
621 throw regionalBlocker ( 'SITE_REGIONAL_WRITE_REJECTED' , `${ write . kind } update failed: ${ error . message }` );
622 }
623 }
624
625 let after = properties;
626 if (writes. length ) {
627 try { after = await writers. getSiteProperties (wix); } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; throw regionalBlocker ( 'SITE_REGIONAL_READBACK_MISMATCH' , `read-back failed: ${ error . message }` ); }
628 if ( ! after) throw regionalBlocker ( 'SITE_REGIONAL_READBACK_MISMATCH' , 'read-back returned nothing' );
629 }
630 const before = ( await readBefore (projectDir, siteId)) || { values: Object. fromEntries ( SETTINGS . map (( field ) => [field, currentValueOf (properties, field)])) };
631 const fields = {};
632 for ( const row of rows) {
633 const field = row.field;
634 const entry = { source: row.source, before: before.values[field] !== undefined ? before.values[field] : row.current, after: currentValueOf (after, field), action: null , verified: false };
635 if (row.code) entry.code = row.code;
636 if (row.gatedRefs. length && row.code) entry.gatedRefs = row.gatedRefs;
637 if (row.decisionRef) entry.decisionRef = row.decisionRef;
638 if (row.action === 'apply' ) {
639 const stored = sr. sameValue (field, row.source, entry.after);
640 if (stored) { entry.action = 'applied' ; entry.verified = true ; }
641 else throw regionalBlocker ( 'SITE_REGIONAL_READBACK_MISMATCH' , `${ field }: sent ${ row . source }, read back ${ entry . after }` );
642 } else if (row.action === 'match' ) { entry.action = 'match' ; entry.verified = true ; }
643 else if (row.action === 'skip-unknown' && field === 'timeZone' && sr. keepsSiteTimeZone (source)) {
644 // Nothing was sent; the site keeps its own zone, read back unchanged. Confirmed at cutover.
645 entry.action = 'kept-site-default' ; entry.reason = row.reason; entry.verified = sr. sameValue (field, row.current, entry.after);
646 } else if (row.action === 'skip-unknown' ) { entry.action = 'skipped-unknown' ; entry.reason = row.reason; }
647 else if (row.action === 'kept' ) { entry.action = 'kept' ; entry.verified = sr. sameValue (field, row.current, entry.after); }
648 fields[field] = entry;
649 }
650 const beforeArtifact = await readBefore (projectDir, siteId);
651 let paymentMethodsConfirmationRequestId = null ;
652 if (beforeArtifact && (beforeArtifact.attemptedFields || []). includes ( 'currency' )
653 && sr. sameValue ( 'currency' , sr. settingValue (source, 'currency' ), currentValueOf (after, 'currency' ))) {
654 const request = await ensurePaymentMethodsAction (projectDir, {
655 siteId,
656 before: beforeArtifact.values.currency,
657 after: currentValueOf (after, 'currency' ),
658 });
659 paymentMethodsConfirmationRequestId = request && request.requestId;
660 }
661 let timeZoneConfirmationRequestId = null ;
662 if (source.timeZone?.inference && fields.timeZone?.verified && [ 'applied' , 'match' , 'kept-site-default' ]. includes (fields.timeZone.action)) {
663 const request = await ensureTimeZoneAction (projectDir, { siteId, row: source.timeZone, siteValue: fields.timeZone.after });
664 timeZoneConfirmationRequestId = request && request.requestId;
665 }
666 const keptFields = [ 'currency' , 'timeZone' ]. filter (( field ) => fields[field]?.action === 'kept' );
667 let heldCatchUp = priorRegional?.heldCatchUp || null ;
668 if (keptFields. length ) {
669 const refs = new Set (requirement.unclassifiedRefs || []);
670 if (keptFields. includes ( 'currency' )) for ( const ref of requirement.moneyBearingRefs || []) refs. add (ref);
671 if (keptFields. includes ( 'timeZone' )) for ( const ref of requirement.wallClockRefs || []) refs. add (ref);
672 heldCatchUp = { fields: keptFields, refs: [ ... refs]. sort () };
673 }
674 const receipt = {
675 siteOrigin,
676 siteState,
677 sourceDigest: fileSha256 (projectDir, sr. SOURCE_REGIONAL_FILE ),
678 decisionDigest: approvalDigest (projectDir, siteId),
679 fields,
680 ... (heldCatchUp?.refs?. length ? { heldCatchUp } : {}),
681 ... (paymentMethodsConfirmationRequestId ? { paymentMethodsConfirmationRequestId } : {}),
682 ... (timeZoneConfirmationRequestId ? { timeZoneConfirmationRequestId } : {}),
683 };
684 return receipt;
685 }
686
687 module . exports = {
688 SETTINGS,
689 STEP_ID,
690 ENTITY_REF,
691 BEFORE_FILE,
692 DECISION_KEY,
693 ANSWER_EFFECT,
694 ANSWER_CHOICES,
695 TIME_ZONE_CONFIRMATION,
696 MONEY_BEARING_REFS,
697 WALL_CLOCK_REFS,
698 REGIONAL_NEUTRAL_FAMILIES,
699 REGIONAL_NEUTRAL_REFS,
700 APP_IDS,
701 SITE_USE_FAMILIES,
702 isMoneyBearingRef,
703 isWallClockRef,
704 isRegionalNeutralRef,
705 classifiedRef,
706 partitionRefs,
707 stepConfiguresSiteRegional,
708 declaredRequirements,
709 planHasRegionalStep,
710 siteOriginFor,
711 destinationSiteId,
712 buildRegionalRequirement,
713 buildRegionalStep,
714 entityRefsForProject,
715 emitRegionalSetup,
716 readDecisions,
717 approvalDigest,
718 approvalIsValid,
719 changesDigestOf,
720 currentValueOf,
721 planSiteRegional,
722 pendingApproval,
723 probeSiteUse,
724 describeValue,
725 renderRegionalPreview,
726 previewRegionalPlan,
727 questionInput,
728 listRegionalRequests,
729 raiseApprovalRequest,
730 reopenRegionalRequest,
731 recordSiteRegionalApproval,
732 regionalDryRunIntent,
733 executeRegionalStep,
734 fileSha256,
735 readJsonSync,
736 writeJsonAtomic,
737 };
738
739 // The task dependency check and the report rows are separate files but one public module.
740 Object. assign ( module . exports , require ( './site-regional-gate.js' ), require ( './site-regional-report.js' ));