This commit implements the full inbound quote email processing workflow and atomic quote numbering: - Add PocketBase collections for monthly atomic quote counters, email attachments, and threaded replies - Add idempotent migration script to set up new collections and add deduplication field to existing quotes table - Implement `/api/v1/quotes/next-number` endpoint for properly formatted Q-YYYY-MM-NNN quote numbers - Add MCP tools for idempotent quote creation, reply threading, and message ID lookup - Add end-to-end and concurrent smoke tests for pipeline validation - Update TypeScript types for new quote-related entities - Update project documentation and package.json scripts - Mark quote email intake as complete in the parity backlog - Add n8n v2 workflows for automated email monitoring and error alerts
118 lines
3.9 KiB
JavaScript
118 lines
3.9 KiB
JavaScript
// Plan: Upload Attachments Early v3 — minimal change from v2:
|
|
// - Don't re-anchor binary under 'attachment'; preserve original keys
|
|
// - Read context from inputs[0].json (the Set node output) AND fallback
|
|
// to $('PocketBase Connection').item.json
|
|
// - HTTP node downstream uses {{ $binary[$json.binary_key] }} like v4
|
|
//
|
|
// Why v2 broke: re-anchoring binary under a new key plus the HTTP node's
|
|
// multipart file param `$binary.attachment` triggered a n8n streams error
|
|
// ("source.on is not a function"). Sticking to the original key (which
|
|
// matches the input binary's keys, e.g. "attachment_0") makes the HTTP
|
|
// node happy.
|
|
|
|
const inputs = $input.all();
|
|
if (inputs.length === 0) {
|
|
return [{ json: { _attachment_count: 0 }, binary: {} }];
|
|
}
|
|
|
|
// Read PB admin creds from PocketBase Connection (Set node upstream) so
|
|
// we're not at the mercy of $json shape after the Set node runs.
|
|
let pbCtx = {};
|
|
try {
|
|
pbCtx = $('PocketBase Connection').item.json || {};
|
|
} catch (e) {
|
|
pbCtx = inputs[0].json || {};
|
|
}
|
|
|
|
// Also pull email context from the first input item (PocketBase Connection
|
|
// preserves it via includeOtherFields: true).
|
|
const emailCtx = inputs[0].json || {};
|
|
|
|
const pbUrl = String(pbCtx.pb_url || '');
|
|
const pbEmail = String(pbCtx.pb_admin_email || '');
|
|
const pbPassword = String(pbCtx.pb_admin_password || '');
|
|
const messageId = String(emailCtx.message_id || '');
|
|
const effectiveMessageId = String(emailCtx._effective_message_id || messageId);
|
|
|
|
if (!pbUrl || !pbEmail || !pbPassword) {
|
|
throw new Error('PB credentials missing from PocketBase Connection');
|
|
}
|
|
|
|
// Mint admin token INSIDE this Code node so binary on inputs is preserved
|
|
// (an HTTP Request node between here and the source would strip it).
|
|
let pbToken = '';
|
|
let pbAdminId = '';
|
|
try {
|
|
const authResp = await this.helpers.httpRequest({
|
|
method: 'POST',
|
|
url: pbUrl + '/api/collections/_superusers/auth-with-password',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: { identity: pbEmail, password: pbPassword },
|
|
json: true,
|
|
timeout: 15000,
|
|
});
|
|
pbToken = String(authResp.token || '');
|
|
pbAdminId = String((authResp.admin && authResp.admin.id) || '');
|
|
if (!pbToken) {
|
|
throw new Error('PB auth returned no token: ' + JSON.stringify(authResp));
|
|
}
|
|
} catch (authErr) {
|
|
throw new Error('PB admin auth failed: ' + (authErr.message || authErr));
|
|
}
|
|
|
|
// Emit one item per attachment. PRESERVE the original binary keys so the
|
|
// downstream HTTP node can use {{ $binary[$json.binary_key] }} (v4 pattern).
|
|
const out = [];
|
|
let attachmentIndex = 0;
|
|
for (const input of inputs) {
|
|
const bin = input.binary || {};
|
|
// Preserve the original binary blob under each original key
|
|
const outBin = {};
|
|
for (const key of Object.keys(bin)) {
|
|
outBin[key] = bin[key];
|
|
}
|
|
for (const key of Object.keys(bin)) {
|
|
const b = bin[key];
|
|
if (!b) continue;
|
|
const filename = String(b.fileName || 'attachment');
|
|
const contentType = String(b.mimeType || 'application/octet-stream');
|
|
out.push({
|
|
json: Object.assign({}, emailCtx, {
|
|
pb_url: pbUrl,
|
|
pb_token: pbToken,
|
|
pb_admin_id: pbAdminId,
|
|
email_message_id: effectiveMessageId,
|
|
filename: filename,
|
|
content_type: contentType,
|
|
size_bytes: Number(b.fileSize || 0),
|
|
binary_key: key,
|
|
_upload_index: attachmentIndex,
|
|
}),
|
|
binary: outBin, // same binary blob, original keys
|
|
});
|
|
attachmentIndex++;
|
|
// Only emit one item per binary blob, not one per key, to avoid dup
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (out.length === 0) {
|
|
// No attachments — emit a single placeholder with no binary
|
|
out.push({
|
|
json: Object.assign({}, emailCtx, {
|
|
pb_url: pbUrl,
|
|
pb_token: pbToken,
|
|
pb_admin_id: pbAdminId,
|
|
email_message_id: effectiveMessageId,
|
|
filename: '',
|
|
content_type: '',
|
|
size_bytes: 0,
|
|
binary_key: '__none__',
|
|
_upload_index: 0,
|
|
_no_attachments: true,
|
|
}),
|
|
binary: {},
|
|
});
|
|
}
|
|
|
|
return out;
|