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>
This commit is contained in:
parent
abd0d8b7a5
commit
6582ec5641
156
jobStore.js
Normal file
156
jobStore.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
42
server.js
42
server.js
@ -10,7 +10,8 @@ const manageProducts = require('./routes/manageProducts');
|
|||||||
const managepricing = require('./routes/managePricing');
|
const managepricing = require('./routes/managePricing');
|
||||||
|
|
||||||
const privacyLawWebhooks = require('./routes/privacyLawWebhooks');
|
const privacyLawWebhooks = require('./routes/privacyLawWebhooks');
|
||||||
const { getToken } = require('./tokenStore');
|
const { getToken, listTokens } = require('./tokenStore');
|
||||||
|
const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3002;
|
const PORT = process.env.PORT || 3002;
|
||||||
@ -18,6 +19,45 @@ const PORT = process.env.PORT || 3002;
|
|||||||
// 0) CORS (safe before everything)
|
// 0) CORS (safe before everything)
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ ok: true, uptime: process.uptime(), timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top-level job endpoints (mirrors manageproducts/jobs/* but at /jobs/*)
|
||||||
|
app.get('/jobs', (req, res) => {
|
||||||
|
const shop = req.query.shop || null;
|
||||||
|
res.json({ jobs: listJobs(shop) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/jobs/:jobId', (req, res) => {
|
||||||
|
const job = getJob(req.params.jobId);
|
||||||
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||||
|
res.json(job);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/jobs/:jobId/cancel', (req, res) => {
|
||||||
|
const job = cancelJob(req.params.jobId);
|
||||||
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||||
|
res.json({ ok: true, job });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/shops', (req, res) => {
|
||||||
|
try {
|
||||||
|
const store = listTokens();
|
||||||
|
const shops = Object.keys(store).map(shop => ({
|
||||||
|
shop,
|
||||||
|
savedAt: store[shop].savedAt,
|
||||||
|
hasToken: !!store[shop].accessToken,
|
||||||
|
hasLocation: !!store[shop].locationId,
|
||||||
|
hasFulfillment: !!store[shop].fulfillmentService,
|
||||||
|
}));
|
||||||
|
res.json({ shops });
|
||||||
|
} catch {
|
||||||
|
res.json({ shops: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/checkisshopdataexists/:shop", (req, res) => {
|
app.get("/checkisshopdataexists/:shop", (req, res) => {
|
||||||
const shop = req.params.shop;
|
const shop = req.params.shop;
|
||||||
console.log("GET /checkisshopdataexists:", shop);
|
console.log("GET /checkisshopdataexists:", shop);
|
||||||
|
|||||||
@ -139,4 +139,8 @@ function getToken(shop) {
|
|||||||
return store[shop] || null;
|
return store[shop] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { saveToken, getToken };
|
function listTokens() {
|
||||||
|
return readStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { saveToken, getToken, listTokens };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user