MOHAN 6582ec5641 feat: add live import progress tracking with job store
- Add jobStore.js: in-memory job store with rich job objects (liveStats,
  logs, errors, cancellation, timing, success rate)
- Rewrite manageProducts.js: structured logging ([STATS], [PRODUCT-OK],
  [PRODUCT-FAIL], [SKIP], [FETCH], [CANCEL], etc.), per-product cancel
  checks, jobStore integration
- server.js: expose GET /health, GET /jobs, GET /jobs/:id,
  POST /jobs/:id/cancel, GET /shops endpoints
- tokenStore.js: add listTokens() export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 02:22:58 +05:30

157 lines
3.6 KiB
JavaScript

// jobStore.js — in-memory job tracker for product import jobs
const { v4: uuid } = require('uuid');
const jobs = {};
const MAX_LOGS = 120;
const MAX_ERRORS = 30;
const MAX_RESULTS = 100;
function nowIso() {
return new Date().toISOString();
}
function elapsedSeconds(startedAt) {
if (!startedAt) return 0;
return Number(((Date.now() - new Date(startedAt).getTime()) / 1000).toFixed(1));
}
function createJob({ shop, brandId, brandName, totalSelected = 0 }) {
const id = uuid();
const job = {
id,
shop,
brandId: String(brandId || ''),
brandName: brandName || '',
status: 'started',
step: 'started',
detail: 'Initialising import job...',
currentProduct: null,
liveStats: {
total: totalSelected,
processed: 0,
created: 0,
skipped: 0,
failed: 0,
remaining: totalSelected,
successRate: 0,
label: `0/${totalSelected}`,
},
errors: [],
logs: [],
results: [],
startedAt: nowIso(),
updatedAt: nowIso(),
finishedAt: null,
durationSeconds: null,
cancelled: false,
};
jobs[id] = job;
return job;
}
function updateJob(jobId, patch) {
const job = jobs[jobId];
if (!job) return null;
Object.assign(job, patch, { updatedAt: nowIso() });
// Recompute derived stats whenever liveStats is patched
if (patch.liveStats) {
const s = job.liveStats;
s.remaining = Math.max(0, s.total - s.processed);
s.successRate = s.processed > 0
? Number((((s.processed - s.failed) / s.processed) * 100).toFixed(1))
: 0;
s.label = `${s.processed}/${s.total}`;
}
return job;
}
function appendJobLog(jobId, line, extraPatch = {}) {
const job = jobs[jobId];
if (!job) return null;
job.logs.push({ at: nowIso(), line });
if (job.logs.length > MAX_LOGS) job.logs.shift();
Object.assign(job, extraPatch, { updatedAt: nowIso() });
return job;
}
function recordProductResult(jobId, result) {
const job = jobs[jobId];
if (!job) return null;
job.results.push(result);
if (job.results.length > MAX_RESULTS) job.results.shift();
job.updatedAt = nowIso();
return job;
}
function recordProductError(jobId, errorEntry) {
const job = jobs[jobId];
if (!job) return null;
job.errors.push(errorEntry);
if (job.errors.length > MAX_ERRORS) job.errors.shift();
job.updatedAt = nowIso();
return job;
}
function finishJob(jobId, status = 'done') {
const job = jobs[jobId];
if (!job) return null;
const finishedAt = nowIso();
const durationSeconds = elapsedSeconds(job.startedAt);
const s = job.liveStats;
Object.assign(job, {
status,
step: status === 'done' ? 'completed' : status,
finishedAt,
durationSeconds,
updatedAt: finishedAt,
});
s.remaining = 0;
s.successRate = s.processed > 0
? Number((((s.processed - s.failed) / s.processed) * 100).toFixed(1))
: 0;
return job;
}
function cancelJob(jobId) {
const job = jobs[jobId];
if (!job) return null;
job.cancelled = true;
job.status = 'cancelling';
job.updatedAt = nowIso();
return job;
}
function isJobCancelled(jobId) {
return !!(jobs[jobId]?.cancelled);
}
function getJob(jobId) {
return jobs[jobId] || null;
}
function listJobs(shop = null) {
const all = Object.values(jobs);
const filtered = shop ? all.filter(j => j.shop === shop) : all;
return filtered.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt));
}
function getLatestJobForShop(shop) {
const shopJobs = listJobs(shop);
return shopJobs[0] || null;
}
module.exports = {
createJob,
updateJob,
appendJobLog,
recordProductResult,
recordProductError,
finishJob,
cancelJob,
isJobCancelled,
getJob,
listJobs,
getLatestJobForShop,
};