/** * @file draftSync.js * @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery). * Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat) * with backend VFS requests and broadcasts. * * Incoming Signals: * - SSE 'draft:request': Backend requests a draft (read or list). * - SSE 'draft': Backend broadcasts an external update to a draft. * * Outgoing Calls: * - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list. */ import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat'; import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat'; import { vfs } from '../api/vfs'; import { getStoredClientId } from '../api/http'; import { getSseClientId } from './sseAutomat'; /** * Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery'). * Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc. * * @param {string} [name] * @returns {string} Normalized key */ export function normalizeDraftName(name) { if (!name) return ''; return String(name) .trim() .replace(/^\/?(api\/)?vfs\//, '') .replace(/^drafts\//, '') .replace(/\.json$/, '') .toLowerCase(); } /** * Supported virtual draft names. */ export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery']; /** * Timestamps of last modifications per draft. * @type {Map} */ const draftTimestamps = new Map(); /** * Resolves active draft content for a given draft name from its managing automat. * * @param {string} rawName * @returns {object|null} */ export function getActiveDraftContent(rawName) { const name = normalizeDraftName(rawName); if (name === 'quote') { return quoteDraftAutomat.actions.getManifest(); } if (name === 'order') { return orderDraftAutomat.actions.getManifest(); } if (name === 'delivery') { return deliveryEditorAutomat.actions.getManifest(); } return null; } /** * Generates the list of virtual draft files for the VFS listing. * * @returns {Array<{ name: string, size: number, updated_at: string }>} */ export function getActiveDraftsList() { const now = new Date().toISOString(); return SUPPORTED_DRAFTS.map((name) => { const content = getActiveDraftContent(name); let size = 0; if (content != null) { try { size = JSON.stringify(content).length; } catch {} } const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z'); return { name, size, updated_at: updatedAt, }; }); } /** * Applies incoming draft content update to the managing automat. * * @param {string} rawName * @param {object|string} rawContent */ export function applyDraftUpdate(rawName, rawContent) { const name = normalizeDraftName(rawName); if (!name) return; let content = rawContent; if (typeof content === 'string') { try { content = JSON.parse(content); } catch (err) { console.error(`[draft] failed to parse incoming draft string for ${name}:`, err); return; } } draftTimestamps.set(name, new Date().toISOString()); try { if (name === 'quote') { quoteDraftAutomat.actions.setManifest(content); } else if (name === 'order') { orderDraftAutomat.actions.setManifest(content); } else if (name === 'delivery') { const manifest = content?.manifest || content || {}; const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? []; const lines = Array.isArray(rawLines) ? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l))) : []; const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null; const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null; deliveryEditorAutomat.actions.setDelivery({ ...manifest, partyId, deliveryEntity: partyId, externalNumber, external_number: externalNumber, lines, }); } } catch (err) { console.error(`[draft] error applying draft update to ${name} automat:`, err); } } /** * Handles incoming 'draft:request' SSE event from backend. * * @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload * @returns {Promise} */ export async function handleDraftRequest(payload) { if (!payload || !payload.requestId) return; const { requestId, action, name, target } = payload; try { if (action === 'read') { const draftName = normalizeDraftName(name || target); const content = getActiveDraftContent(draftName); if (content != null) { await vfs.respondDraft({ requestId, content }); } else { await vfs.respondDraft({ requestId, error: 'Draft not open' }); } } else if (action === 'list') { const files = getActiveDraftsList(); await vfs.respondDraft({ requestId, files }); } } catch (err) { console.error('[draft] error handling draft:request:', err); } } /** * Handles incoming 'draft' SSE broadcast from backend. * * @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload */ export function handleDraftEvent(payload) { if (!payload) return; const { sender, action, name, target, content } = payload; const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null); if (sender && myClientId && sender === myClientId) { // Ignore self-echo return; } if (action === 'update') { const draftName = normalizeDraftName(name || target); applyDraftUpdate(draftName, content); } }