* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
266 lines
16 KiB
JavaScript
266 lines
16 KiB
JavaScript
import { randomUUID, createHash } from 'crypto';
|
|
import { db, getListing, createPublishLog, updatePublishLog } from './db.mjs';
|
|
|
|
const ACTIVE = new Set(['queued', 'running', 'awaiting_user', 'retry_wait']);
|
|
const TERMINAL = new Set(['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked']);
|
|
const DIRECT = new Set(['ebay-de', 'etsy']);
|
|
let timer = null;
|
|
let processing = false;
|
|
let processors = null;
|
|
|
|
function parseJson(value, fallback = {}) {
|
|
try { return JSON.parse(value || ''); } catch { return fallback; }
|
|
}
|
|
function serialize(row) {
|
|
if (!row) return null;
|
|
return { ...row, payload: parseJson(row.payload_json), result: parseJson(row.result_json) };
|
|
}
|
|
function modeFor(platform) { return DIRECT.has(platform) ? 'api' : 'extension'; }
|
|
function keyFor(listingId, platform, action, nonce = '') {
|
|
return createHash('sha256').update(`${listingId}|${platform}|${action}|${nonce}`).digest('hex');
|
|
}
|
|
function addEvent(job, eventType, message, level = 'info', details = {}) {
|
|
db.prepare(`INSERT INTO publishing_events (job_id, listing_id, platform, level, event_type, message, details_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(job?.id || null, job.listing_id, job.platform, level, eventType, message, JSON.stringify(details || {}));
|
|
}
|
|
function latestPublished(listingId, platform) {
|
|
return db.prepare(`SELECT * FROM publish_log WHERE listing_id = ? AND platform = ? AND status = 'published' ORDER BY id DESC LIMIT 1`).get(listingId, platform);
|
|
}
|
|
function currentActive(listingId, platform, action) {
|
|
return db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND action = ?
|
|
AND status IN ('queued','running','awaiting_user','retry_wait') ORDER BY created_at DESC LIMIT 1`).get(listingId, platform, action);
|
|
}
|
|
export function enqueuePublishingJob({ listingId, platform, action = 'publish', force = false, payload = {}, maxRetries = 3 } = {}) {
|
|
const listing = getListing(Number(listingId));
|
|
if (!listing) throw new Error('Artikel nicht gefunden');
|
|
const target = platform || listing.platform;
|
|
if (!target) throw new Error('Plattform fehlt');
|
|
const existing = currentActive(listing.id, target, action);
|
|
if (existing) return { job: serialize(existing), duplicate: true };
|
|
if (action === 'publish' && !force) {
|
|
const live = latestPublished(listing.id, target);
|
|
if (live?.external_id) {
|
|
const error = new Error('Artikel ist auf dieser Plattform bereits als veröffentlicht protokolliert');
|
|
error.code = 'DUPLICATE_PUBLISH';
|
|
error.existing = live;
|
|
throw error;
|
|
}
|
|
}
|
|
const id = randomUUID();
|
|
const mode = modeFor(target);
|
|
const status = mode === 'extension' && action === 'publish' ? 'awaiting_user' : 'queued';
|
|
const nonce = force ? randomUUID() : `${new Date().toISOString()}`;
|
|
const key = keyFor(listing.id, target, action, nonce);
|
|
db.prepare(`INSERT INTO publishing_jobs
|
|
(id, listing_id, platform, mode, action, status, phase, idempotency_key, max_retries, payload_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
.run(id, listing.id, target, mode, action, status, status, key, Math.min(Math.max(Number(maxRetries) || 3, 0), 10), JSON.stringify(payload || {}));
|
|
const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id));
|
|
addEvent(job, 'created', mode === 'api' ? 'Publishing-Auftrag eingereiht' : 'Auftrag wartet auf Browser-Extension');
|
|
return { job, duplicate: false };
|
|
}
|
|
export function getPublishingJob(id) {
|
|
const row = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id WHERE j.id = ?`).get(id);
|
|
if (!row) return null;
|
|
const job = serialize(row);
|
|
job.events = db.prepare('SELECT * FROM publishing_events WHERE job_id = ? ORDER BY id DESC LIMIT 100').all(id).map(event => ({ ...event, details: parseJson(event.details_json) }));
|
|
return job;
|
|
}
|
|
export function paginatePublishingJobs({ page = 1, pageSize = 10, status = '', platform = '', q = '' } = {}) {
|
|
const size = [5, 10, 15, 25, 50].includes(Number(pageSize)) ? Number(pageSize) : 10;
|
|
const current = Math.max(1, Number(page) || 1);
|
|
const where = [];
|
|
const params = [];
|
|
if (status) { where.push('j.status = ?'); params.push(status); }
|
|
if (platform) { where.push('j.platform = ?'); params.push(platform); }
|
|
if (q) { where.push('(l.title LIKE ? OR l.sku LIKE ? OR j.external_id LIKE ? OR j.error_message LIKE ?)'); const needle = `%${q}%`; params.push(needle, needle, needle, needle); }
|
|
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
const total = db.prepare(`SELECT COUNT(*) AS count FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause}`).get(...params).count;
|
|
const rows = db.prepare(`SELECT j.*, l.title, l.sku, l.photos FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id ${clause}
|
|
ORDER BY CASE j.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 WHEN 'awaiting_user' THEN 2 WHEN 'retry_wait' THEN 3 WHEN 'failed' THEN 4 ELSE 5 END, j.created_at DESC
|
|
LIMIT ? OFFSET ?`).all(...params, size, (current - 1) * size);
|
|
const summaryRows = db.prepare(`SELECT status, COUNT(*) AS count FROM publishing_jobs GROUP BY status`).all();
|
|
return {
|
|
items: rows.map(serialize),
|
|
pagination: { page: current, page_size: size, total, total_pages: Math.max(1, Math.ceil(total / size)) },
|
|
summary: Object.fromEntries(summaryRows.map(row => [row.status, row.count])),
|
|
};
|
|
}
|
|
export function listPublishingEvents({ limit = 100, level = '', platform = '' } = {}) {
|
|
const where = [];
|
|
const params = [];
|
|
if (level) { where.push('e.level = ?'); params.push(level); }
|
|
if (platform) { where.push('e.platform = ?'); params.push(platform); }
|
|
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
return db.prepare(`SELECT e.*, l.title, l.sku FROM publishing_events e JOIN listings l ON l.id = e.listing_id ${clause} ORDER BY e.id DESC LIMIT ?`)
|
|
.all(...params, Math.min(Math.max(Number(limit) || 100, 1), 500)).map(row => ({ ...row, details: parseJson(row.details_json) }));
|
|
}
|
|
export function retryPublishingJob(id) {
|
|
const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id);
|
|
if (!job) throw new Error('Auftrag nicht gefunden');
|
|
if (!['failed', 'blocked', 'cancelled'].includes(job.status)) throw new Error('Nur fehlgeschlagene, blockierte oder abgebrochene Aufträge können wiederholt werden');
|
|
const next = modeFor(job.platform) === 'extension' && job.action === 'publish' ? 'awaiting_user' : 'queued';
|
|
db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, error_code = NULL, error_message = NULL, next_attempt_at = NULL,
|
|
completed_at = NULL, updated_at = datetime('now') WHERE id = ?`).run(next, next, id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id));
|
|
addEvent(updated, 'retry', 'Auftrag erneut eingeplant');
|
|
return updated;
|
|
}
|
|
export function cancelPublishingJob(id) {
|
|
const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id);
|
|
if (!job) throw new Error('Auftrag nicht gefunden');
|
|
if (TERMINAL.has(job.status)) return serialize(job);
|
|
db.prepare(`UPDATE publishing_jobs SET status = 'cancelled', phase = 'cancelled', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`).run(id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id));
|
|
addEvent(updated, 'cancelled', 'Auftrag abgebrochen', 'warning');
|
|
return updated;
|
|
}
|
|
export function deletePublishingJobs(ids = []) {
|
|
const safe = [...new Set(ids.map(String).filter(Boolean))];
|
|
if (!safe.length) return 0;
|
|
const placeholders = safe.map(() => '?').join(',');
|
|
const rows = db.prepare(`SELECT id, status FROM publishing_jobs WHERE id IN (${placeholders})`).all(...safe);
|
|
if (rows.some(row => ACTIVE.has(row.status))) throw new Error('Aktive Aufträge können nicht gelöscht werden');
|
|
return db.prepare(`DELETE FROM publishing_jobs WHERE id IN (${placeholders})`).run(...safe).changes;
|
|
}
|
|
export function claimExtensionJob({ platform = 'vinted', clientId = 'extension' } = {}) {
|
|
const job = db.prepare(`SELECT * FROM publishing_jobs WHERE platform = ? AND mode = 'extension' AND status = 'awaiting_user'
|
|
ORDER BY created_at LIMIT 1`).get(platform);
|
|
if (!job) return null;
|
|
db.prepare(`UPDATE publishing_jobs SET claimed_by = ?, claimed_at = datetime('now'), phase = 'claimed', updated_at = datetime('now') WHERE id = ?`).run(clientId, job.id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id));
|
|
addEvent(updated, 'claimed', 'Auftrag von Browser-Extension übernommen', 'info', { clientId });
|
|
return { ...updated, listing: getListing(updated.listing_id) };
|
|
}
|
|
export function completeExtensionJob(id, { status = 'published', externalId = null, externalUrl = null, error = null } = {}) {
|
|
const job = db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id);
|
|
if (!job) throw new Error('Auftrag nicht gefunden');
|
|
const nextStatus = status === 'published' ? 'published' : status === 'draft' ? 'awaiting_user' : 'failed';
|
|
db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url),
|
|
error_message = ?, completed_at = CASE WHEN ? IN ('published','failed') THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`)
|
|
.run(nextStatus, nextStatus, externalId, externalUrl, error, nextStatus, id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id));
|
|
if (nextStatus === 'published') createPublishLog(updated.listing_id, updated.platform, 'published', externalId, externalUrl);
|
|
addEvent(updated, nextStatus, nextStatus === 'published' ? 'Extension-Auftrag als veröffentlicht bestätigt' : (error || 'Extension-Auftrag aktualisiert'), nextStatus === 'failed' ? 'error' : 'info');
|
|
return updated;
|
|
}
|
|
function markRunning(job) {
|
|
db.prepare(`UPDATE publishing_jobs SET status = 'running', phase = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.action, job.id);
|
|
return serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id));
|
|
}
|
|
function markSuccess(job, result = {}) {
|
|
const status = job.action === 'end' ? 'ended' : (job.action === 'sync' ? (result.status || 'published') : (String(result.status || '').toLowerCase() === 'draft' ? 'drafted' : 'published'));
|
|
db.prepare(`UPDATE publishing_jobs SET status = ?, phase = 'completed', external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url),
|
|
offer_id = COALESCE(?, offer_id), result_json = ?, error_code = NULL, error_message = NULL, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`)
|
|
.run(status, result.externalId || result.listingId || null, result.url || null, result.offerId || null, JSON.stringify(result || {}), job.id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id));
|
|
if (job.action === 'publish') {
|
|
createPublishLog(job.listing_id, job.platform, status === 'drafted' ? 'draft' : 'published', updated.external_id, updated.external_url);
|
|
const createdLog = db.prepare('SELECT id FROM publish_log WHERE listing_id = ? AND platform = ? ORDER BY id DESC LIMIT 1').get(job.listing_id, job.platform);
|
|
if (createdLog) updatePublishLog(createdLog.id, { offer_id: updated.offer_id, sync_status: status, last_synced_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
|
} else {
|
|
const log = latestPublished(job.listing_id, job.platform);
|
|
if (log) updatePublishLog(log.id, {
|
|
status: job.action === 'end' ? 'ended' : (result.status || log.status),
|
|
external_id: updated.external_id,
|
|
external_url: updated.external_url,
|
|
offer_id: updated.offer_id,
|
|
sync_status: result.status || status,
|
|
last_synced_at: new Date().toISOString(),
|
|
ended_at: job.action === 'end' ? new Date().toISOString() : undefined,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
addEvent(updated, 'completed', job.action === 'end' ? 'Angebot beendet' : job.action === 'sync' ? 'Status synchronisiert' : 'Veröffentlichung erfolgreich');
|
|
return updated;
|
|
}
|
|
function markFailure(job, error) {
|
|
const retries = Number(job.retries || 0) + 1;
|
|
const canRetry = retries <= Number(job.max_retries || 3);
|
|
const delayMinutes = Math.min(30, Math.max(1, 2 ** Math.max(0, retries - 1)));
|
|
const status = canRetry ? 'retry_wait' : 'failed';
|
|
db.prepare(`UPDATE publishing_jobs SET status = ?, phase = ?, retries = ?, error_code = ?, error_message = ?,
|
|
next_attempt_at = CASE WHEN ? = 'retry_wait' THEN datetime('now', '+' || ? || ' minutes') ELSE NULL END,
|
|
completed_at = CASE WHEN ? = 'failed' THEN datetime('now') ELSE NULL END, updated_at = datetime('now') WHERE id = ?`)
|
|
.run(status, status, retries, error?.code || 'PUBLISH_ERROR', error?.message || String(error), status, delayMinutes, status, job.id);
|
|
const updated = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(job.id));
|
|
addEvent(updated, status, error?.message || String(error), 'error', { retries, nextRetryMinutes: canRetry ? delayMinutes : null });
|
|
return updated;
|
|
}
|
|
async function processOne() {
|
|
if (!processors) return;
|
|
const row = db.prepare(`SELECT * FROM publishing_jobs WHERE mode = 'api' AND (
|
|
status = 'queued' OR (status = 'retry_wait' AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now')))
|
|
) ORDER BY created_at LIMIT 1`).get();
|
|
if (!row) return;
|
|
const job = markRunning(row);
|
|
try {
|
|
const listing = getListing(job.listing_id);
|
|
if (!listing) throw new Error('Artikel nicht gefunden');
|
|
const result = await processors.process(job, listing);
|
|
markSuccess(job, result || {});
|
|
} catch (error) { markFailure(job, error); }
|
|
}
|
|
export function configurePublishingProcessors(value) { processors = value; }
|
|
export function startPublishingJobLoop(intervalMs = 1500) {
|
|
if (timer) return;
|
|
const tick = async () => {
|
|
if (processing) return;
|
|
processing = true;
|
|
try { await processOne(); } finally { processing = false; }
|
|
};
|
|
timer = setInterval(tick, intervalMs);
|
|
timer.unref?.();
|
|
tick();
|
|
}
|
|
|
|
export function stopPublishingJobLoop() {
|
|
if (timer) clearInterval(timer);
|
|
timer = null;
|
|
processing = false;
|
|
}
|
|
|
|
export function migrateLegacyEbayQueue() {
|
|
let rows = [];
|
|
try { rows = db.prepare('SELECT * FROM ebay_queue ORDER BY id').all(); } catch { return { migrated: 0, skipped: 0 }; }
|
|
let migrated = 0;
|
|
let skipped = 0;
|
|
for (const row of rows) {
|
|
const key = `legacy-ebay-queue-${row.id}`;
|
|
const exists = db.prepare('SELECT id FROM publishing_jobs WHERE idempotency_key = ?').get(key);
|
|
if (exists) { skipped += 1; continue; }
|
|
const statusMap = { pending: 'queued', uploading: 'queued', failed: 'failed', published: 'published' };
|
|
const status = statusMap[row.status] || 'queued';
|
|
const id = randomUUID();
|
|
db.prepare(`INSERT OR IGNORE INTO publishing_jobs
|
|
(id, listing_id, platform, mode, action, status, phase, idempotency_key, external_id, external_url,
|
|
retries, max_retries, error_message, created_at, completed_at, updated_at)
|
|
VALUES (?, ?, 'ebay-de', 'api', 'publish', ?, ?, ?, ?, ?, ?, 3, ?, COALESCE(?, datetime('now')),
|
|
CASE WHEN ? IN ('published','failed') THEN COALESCE(?, datetime('now')) ELSE NULL END, datetime('now'))`)
|
|
.run(id, row.listing_id, status, row.status === 'uploading' ? 'recovered' : status, key,
|
|
row.ebay_item_id || null, row.ebay_url || null, Number(row.retries || 0), row.error || null,
|
|
row.created_at || null, status, row.updated_at || row.created_at || null);
|
|
const job = serialize(db.prepare('SELECT * FROM publishing_jobs WHERE id = ?').get(id));
|
|
if (job) {
|
|
addEvent(job, 'legacy_migrated', 'Eintrag aus der bisherigen eBay-Queue übernommen', 'info', { legacyQueueId: row.id, legacyStatus: row.status });
|
|
migrated += 1;
|
|
}
|
|
}
|
|
return { migrated, skipped };
|
|
}
|
|
|
|
export function recoverPublishingJobs() {
|
|
const changes = db.prepare(`UPDATE publishing_jobs SET status = 'queued', phase = 'recovered', updated_at = datetime('now') WHERE status = 'running'`).run().changes;
|
|
return changes;
|
|
}
|
|
export function getPublishCapabilities() {
|
|
return {
|
|
platforms: {
|
|
'ebay-de': { mode: 'api', publish: true, update: true, end: true, sync: true },
|
|
etsy: { mode: 'api', publish: true, update: false, end: false, sync: true },
|
|
vinted: { mode: 'extension', publish: true, update: false, end: false, sync: false },
|
|
'ebay-ka': { mode: 'extension', publish: true, update: false, end: false, sync: false },
|
|
},
|
|
};
|
|
}
|