u
This commit is contained in:
316
src/services/draftManager.js
Normal file
316
src/services/draftManager.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import { emit, hasActiveClient } from '../api/sse.js';
|
||||
import { getStore } from '../db/store.js';
|
||||
|
||||
export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']);
|
||||
|
||||
/**
|
||||
* Checks and parses a VFS path to determine if it points to the drafts directory or a draft file.
|
||||
* Accepts paths like:
|
||||
* - 'drafts', '/drafts', 'drafts/'
|
||||
* - 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'vfs://drafts/delivery'
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {{ isFolder: boolean, isDraft: boolean, name?: 'quote'|'order'|'delivery', filename?: string }}
|
||||
*/
|
||||
export function parseDraftPath(filePath) {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return { isFolder: false, isDraft: false };
|
||||
}
|
||||
let clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
|
||||
clean = clean.replace(/^api\//i, '').replace(/^vfs\//i, '');
|
||||
const segments = clean.split('/').filter(Boolean);
|
||||
if (segments.length === 0) return { isFolder: false, isDraft: false };
|
||||
|
||||
if (segments[0].toLowerCase() === 'drafts') {
|
||||
if (segments.length === 1) {
|
||||
return { isFolder: true, isDraft: false };
|
||||
}
|
||||
if (segments.length === 2) {
|
||||
const rawName = segments[1];
|
||||
const lower = rawName.toLowerCase();
|
||||
const withoutExt = lower.endsWith('.json') ? lower.slice(0, -5) : lower;
|
||||
if (DRAFT_NAMES.has(withoutExt)) {
|
||||
return { isFolder: false, isDraft: true, name: withoutExt, filename: rawName };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isFolder: false, isDraft: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size }
|
||||
*/
|
||||
const draftCache = new Map();
|
||||
|
||||
function cacheKey(companyId, userId, name) {
|
||||
return `${companyId}:${userId}:${name}`;
|
||||
}
|
||||
|
||||
export function getCachedDraft({ companyId, userId, name }) {
|
||||
return draftCache.get(cacheKey(companyId, userId, name)) || null;
|
||||
}
|
||||
|
||||
export function setCachedDraft({ companyId, userId, name, content }) {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
|
||||
const entry = {
|
||||
content,
|
||||
updatedAt,
|
||||
size: Buffer.byteLength(str, 'utf8'),
|
||||
};
|
||||
draftCache.set(cacheKey(companyId, userId, name), entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected company ID for a given googleAuth ID if needed.
|
||||
* @param {number} googleAuthId
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
export async function resolveCompanyForUser(googleAuthId) {
|
||||
if (!googleAuthId) return null;
|
||||
try {
|
||||
const store = await getStore();
|
||||
const row = await store.google_auth.getById(googleAuthId);
|
||||
return row?.selectedCompanyId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending requests map: requestId -> { resolve, reject, timer, companyId, userId, action, name }
|
||||
*/
|
||||
const pendingRequests = new Map();
|
||||
|
||||
/**
|
||||
* Request a draft content from the frontend via SSE and wait for its response.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number, clientId?: string, name: string, timeoutMs?: number }} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function requestDraftRead({ companyId, userId, clientId, name, timeoutMs = 15000 }) {
|
||||
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
|
||||
if (effectiveCompanyId == null) {
|
||||
throw new Error('No company selected');
|
||||
}
|
||||
|
||||
// Check if client is active; if not and we have cache, return cache immediately
|
||||
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
|
||||
if (!hasClient) {
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return cached.content;
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = `draft_req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
// Fallback to cache if available
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
if (cached) {
|
||||
return resolve(cached.content);
|
||||
}
|
||||
reject(new Error(`Frontend draft response timed out for "${name}"`));
|
||||
}, timeoutMs);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
companyId: effectiveCompanyId,
|
||||
userId,
|
||||
action: 'read',
|
||||
name,
|
||||
});
|
||||
|
||||
// Emit request over SSE
|
||||
emit('draft:request', {
|
||||
companyId: effectiveCompanyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
requestId,
|
||||
action: 'read',
|
||||
name,
|
||||
target: name,
|
||||
sender: clientId || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request draft listing from the frontend via SSE.
|
||||
* If frontend doesn't respond or times out, returns default metadata for all 3 drafts.
|
||||
*
|
||||
* @param {{ companyId?: number, userId: number, clientId?: string, timeoutMs?: number }} options
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
export async function requestDraftList({ companyId, userId, clientId, timeoutMs = 2500 }) {
|
||||
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
|
||||
if (effectiveCompanyId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const defaultDrafts = () => {
|
||||
const now = new Date().toISOString();
|
||||
return Array.from(DRAFT_NAMES).map((name) => {
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: cached ? cached.size : 0,
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: cached ? cached.updatedAt : now,
|
||||
created_at: cached ? cached.updatedAt : now,
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// If no client is connected, return defaults immediately without delaying listing
|
||||
const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId });
|
||||
if (!hasClient) {
|
||||
return defaultDrafts();
|
||||
}
|
||||
|
||||
const requestId = `draft_list_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve(defaultDrafts());
|
||||
}, timeoutMs);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve: (filesOrList) => {
|
||||
if (Array.isArray(filesOrList)) {
|
||||
const now = new Date().toISOString();
|
||||
const normalized = filesOrList.map((item) => {
|
||||
const rawName = typeof item === 'string' ? item : (item.name || item.filename || '');
|
||||
const parsed = parseDraftPath(rawName.startsWith('drafts/') ? rawName : `drafts/${rawName}`);
|
||||
const nameKey = parsed.name || rawName;
|
||||
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name: nameKey });
|
||||
return {
|
||||
name: `drafts/${nameKey}`,
|
||||
size: typeof item === 'object' && item.size != null ? item.size : (cached ? cached.size : 0),
|
||||
mime: 'application/json; charset=utf-8',
|
||||
updated_at: typeof item === 'object' && item.updated_at ? item.updated_at : (cached ? cached.updatedAt : now),
|
||||
created_at: typeof item === 'object' && item.created_at ? item.created_at : (cached ? cached.updatedAt : now),
|
||||
isText: true,
|
||||
isImage: false,
|
||||
isPdf: false,
|
||||
isDraft: true,
|
||||
};
|
||||
});
|
||||
resolve(normalized);
|
||||
} else {
|
||||
resolve(defaultDrafts());
|
||||
}
|
||||
},
|
||||
reject: () => {
|
||||
resolve(defaultDrafts());
|
||||
},
|
||||
timer,
|
||||
companyId: effectiveCompanyId,
|
||||
userId,
|
||||
action: 'list',
|
||||
});
|
||||
|
||||
emit('draft:request', {
|
||||
companyId: effectiveCompanyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
requestId,
|
||||
action: 'list',
|
||||
sender: clientId || '',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle frontend response to a previous draft:request.
|
||||
*
|
||||
* @param {{ requestId: string, content?: any, error?: string, files?: any[] }} payload
|
||||
* @returns {{ handled: boolean, error?: string }}
|
||||
*/
|
||||
export function handleDraftResponse({ requestId, content, error, files }) {
|
||||
if (!requestId || !pendingRequests.has(requestId)) {
|
||||
return { handled: false, error: 'Unknown or expired requestId' };
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(requestId);
|
||||
pendingRequests.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
|
||||
if (error) {
|
||||
pending.reject(new Error(error));
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (pending.action === 'list') {
|
||||
pending.resolve(files !== undefined ? files : content);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
// action === 'read'
|
||||
setCachedDraft({
|
||||
companyId: pending.companyId,
|
||||
userId: pending.userId,
|
||||
name: pending.name,
|
||||
content,
|
||||
});
|
||||
pending.resolve(content);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify frontend about a draft update via the normal /events SSE endpoint.
|
||||
*
|
||||
* @param {{ companyId: number, userId: number, clientId?: string, name: string, content: any }} options
|
||||
* @returns {{ name: string, size: number, updatedAt: string }}
|
||||
*/
|
||||
export function notifyDraftUpdate({ companyId, userId, clientId, name, content }) {
|
||||
const entry = setCachedDraft({ companyId, userId, name, content });
|
||||
|
||||
emit('draft', {
|
||||
companyId,
|
||||
targetUserId: userId,
|
||||
targetClientId: clientId,
|
||||
data: {
|
||||
sender: clientId || '',
|
||||
action: 'update',
|
||||
name,
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
name: `drafts/${name}`,
|
||||
size: entry.size,
|
||||
updatedAt: entry.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous/fire-and-forget version of notifyDraftUpdate for sync writes.
|
||||
*
|
||||
* @param {{ googleAuthId: number, clientId?: string, name: string, content: any }} options
|
||||
*/
|
||||
export function notifyDraftUpdateSync({ googleAuthId, clientId, name, content }) {
|
||||
resolveCompanyForUser(googleAuthId).then((companyId) => {
|
||||
if (companyId != null) {
|
||||
notifyDraftUpdate({ companyId, userId: googleAuthId, clientId, name, content });
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[draftManager] notifyDraftUpdateSync error:', err);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user