import crypto from 'node:crypto'; export const parseUrl = process.env.PARSE_URL || 'http://127.0.0.1:3000/parse'; export const apiUrl = process.env.API_URL || 'http://127.0.0.1:3000/api/amazon'; export const appId = process.env.PARSE_APP_ID; export const masterKey = process.env.PARSE_MASTER_KEY; export const runId = process.env.SP_API_RUN_ID || `spapi-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`; if (!appId || !masterKey) throw new Error('Missing PARSE_APP_ID or PARSE_MASTER_KEY'); export const parseHeaders = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId, 'X-Parse-Master-Key': masterKey, }; export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); export const shopPointer = objectId => ({ __type: 'Pointer', className: 'Shop', objectId }); export const orderPointer = objectId => ({ __type: 'Pointer', className: 'Order', objectId }); export const dateValue = value => { if (!value) return undefined; const date = new Date(value); return Number.isNaN(date.getTime()) ? undefined : { __type: 'Date', iso: date.toISOString() }; }; export function cleanObject(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } export function stableStringify(value) { if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; if (value && typeof value === 'object') { return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; } return JSON.stringify(value); } export function hash(value) { return crypto.createHash('sha256').update(typeof value === 'string' ? value : stableStringify(value)).digest('hex'); } export function log(event, data = {}) { console.log(JSON.stringify({ at: new Date().toISOString(), runId, event, ...data })); } export async function parseRequest(path, method = 'GET', body) { const response = await fetch(`${parseUrl}${path}`, { method, headers: parseHeaders, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); let result = {}; try { result = JSON.parse(text); } catch {} if (!response.ok || result.error) { throw new Error(`${method} ${path}: ${result.error?.message || result.error || text.slice(0, 400) || response.status}`); } return result; } export async function allRows(className, keys = '', where = undefined) { const rows = []; let skip = 0; const whereParam = where === undefined ? '' : `&where=${encodeURIComponent(JSON.stringify(where))}`; while (true) { const page = await parseRequest(`/classes/${className}?limit=1000&skip=${skip}${keys ? `&keys=${keys}` : ''}${whereParam}`); rows.push(...(page.results || [])); const length = page.results?.length || 0; if (length < 1000) return rows; skip += length; } } export async function forward(shopId, path, method = 'GET', body = undefined, maxAttempts = 5) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await fetch(`${apiUrl}/forward`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'shop-objectid': shopId }, body: JSON.stringify({ path, method, ...(body === undefined ? {} : { body }) }), signal: AbortSignal.timeout(180000), }); const text = await response.text(); let result = {}; try { result = JSON.parse(text); } catch {} if (!response.ok || result.success === false) { const error = new Error(result.message || result.errors?.[0]?.message || text.slice(0, 400) || `HTTP ${response.status}`); error.status = Number(result.status || response.status); throw error; } return result.data || result; } catch (error) { if (attempt === maxAttempts || [400, 401, 403, 404, 409, 422].includes(error.status)) throw error; const delay = Math.min(attempt * 15000, 60000); log('forward_retry', { shopId, path: path.split('?')[0], attempt, delay, error: error.message }); await sleep(delay); } } } const classCaches = new Map(); async function classCache(className) { if (!classCaches.has(className)) { const rows = await allRows(className, 'objectId,recordKey'); classCaches.set(className, new Map(rows.filter(row => row.recordKey).map(row => [row.recordKey, row.objectId]))); } return classCaches.get(className); } export async function upsertMany(className, rows) { if (!rows.length) return { inserted: 0, updated: 0 }; rows = [...new Map(rows.map(row => [row.recordKey, row])).values()]; const cache = await classCache(className); let inserted = 0; let updated = 0; for (let start = 0; start < rows.length; start += 25) { const page = rows.slice(start, start + 25); const requests = page.map(row => { if (!row.recordKey) throw new Error(`${className} row is missing recordKey`); const objectId = cache.get(row.recordKey); return { method: objectId ? 'PUT' : 'POST', path: objectId ? `/parse/classes/${className}/${objectId}` : `/parse/classes/${className}`, body: row, }; }); const result = await parseRequest('/batch', 'POST', { requests }); for (let index = 0; index < page.length; index++) { const response = result[index]; if (response?.error) throw new Error(`${className}: ${response.error.error || response.error.code}`); if (cache.has(page[index].recordKey)) { updated++; } else { cache.set(page[index].recordKey, response?.success?.objectId); inserted++; } } } return { inserted, updated }; } export async function updateObject(className, objectId, body) { return parseRequest(`/classes/${className}/${objectId}`, 'PUT', body); } export async function writeAudit({ dataType, endpoint, status, shop, region = '', marketplaceId = '', records = 0, pages = 0, error = '', startedAt, details = {} }) { const finishedAt = new Date(); return parseRequest('/classes/SpApiSyncAudit', 'POST', { runId, dataType, endpoint, status, region, marketplaceId, records, pages, error: String(error || '').slice(0, 2000), startedAt: dateValue(startedAt || finishedAt), finishedAt: dateValue(finishedAt), details, ...(shop?.objectId ? { shop: shopPointer(shop.objectId) } : {}), }); } export const regionByMarketplace = { ATVPDKIKX0DER: 'NA', A2EUQ1WTGCTBG2: 'NA', A1AM78C64UM0Y8: 'NA', A2Q3Y263D00KWC: 'NA', A1F83G8C2ARO7P: 'EU', A1PA6795UKMFR9: 'EU', A13V1IB3VIYZZH: 'EU', A1RKKUPIHCS9HS: 'EU', APJ6JRA9NG5V4: 'EU', A1805IZSGTT6HS: 'EU', A2NODRKZP88ZB9: 'EU', A1C3SOZRARQ6R3: 'EU', AMEN7PMS3EDWL: 'EU', A28R8C7NBKEWEA: 'EU', A2VIGQ35RCS4UG: 'EU', A17E79C6D8DWNP: 'EU', A39IBJ37TRP1C6: 'FE', A19VAU5U5O7RUS: 'FE', }; export async function activeAmazonShops() { const shops = await allRows('Shop'); return shops.filter(shop => shop.platform === 'amazon' && shop.status === 'active' && shop.disable !== true && shop.deleted !== true && shop.config?.SpApiConfig); }