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
116 lines
4.1 KiB
JavaScript
116 lines
4.1 KiB
JavaScript
// Plan: Upload Attachments Early v2 — does PB admin auth INLINE so binary survives.
|
|
// (v1 relied on a pre-computed $json.pb_token from a separate PocketBase
|
|
// Admin Auth HTTP node — but that node strips binary, so v1's uploads had
|
|
// no bytes to send. v2 inlines the auth via this.helpers.httpRequest.)
|
|
//
|
|
// On each invocation:
|
|
// 1. Read PB admin creds from PocketBase Connection (the Set node upstream)
|
|
// 2. POST to /api/collections/_superusers/auth-with-password → get token
|
|
// 3. Walk the binary on the incoming item(s), emit one item per attachment
|
|
// with the freshly-minted token + all the upload prep data
|
|
// 4. Items carry the binary so the next HTTP node can multipart-upload it
|
|
//
|
|
// On no-attachments emails, emits one placeholder item with no binary.
|
|
|
|
const inputs = $input.all();
|
|
if (inputs.length === 0) {
|
|
return [{ json: { _attachment_count: 0 }, binary: {} }];
|
|
}
|
|
|
|
// 1. Read PB admin creds from PocketBase Connection (the Set node upstream).
|
|
// We use $('Node Name').item.json so we don't depend on what $json carries
|
|
// (the HTTP node that previously did the auth replaced $json with its
|
|
// own response shape).
|
|
let ctx = {};
|
|
try {
|
|
ctx = $('PocketBase Connection').item.json || {};
|
|
} catch (e) {
|
|
ctx = inputs[0].json || {};
|
|
}
|
|
const pbUrl = String(ctx.pb_url || '');
|
|
const pbEmail = String(ctx.pb_admin_email || '');
|
|
const pbPassword = String(ctx.pb_admin_password || '');
|
|
const messageId = String(ctx.message_id || '');
|
|
const effectiveMessageId = String(ctx._effective_message_id || messageId);
|
|
|
|
if (!pbUrl || !pbEmail || !pbPassword) {
|
|
throw new Error('PB credentials missing from PocketBase Connection: ' + JSON.stringify({
|
|
hasUrl: !!pbUrl, hasEmail: !!pbEmail, hasPass: !!pbPassword
|
|
}));
|
|
}
|
|
|
|
// 2. POST to PB to mint an admin token. This happens INSIDE the Code node,
|
|
// so binary on the input items is preserved (unlike going through an HTTP
|
|
// Request node, which strips binary).
|
|
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));
|
|
}
|
|
|
|
// 3. Walk binary on every input item and emit one upload-prep item per
|
|
// attachment. The binary is re-anchored under a stable `attachment`
|
|
// property so the next HTTP node can reference it predictably.
|
|
const out = [];
|
|
let attachmentIndex = 0;
|
|
for (const input of inputs) {
|
|
const bin = input.binary || {};
|
|
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: {
|
|
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: { attachment: b },
|
|
});
|
|
attachmentIndex++;
|
|
}
|
|
}
|
|
|
|
if (out.length === 0) {
|
|
// No attachments — emit a placeholder item with no binary so the chain
|
|
// continues. The downstream HTTP POST will see no binary and PB will
|
|
// reject (caught by onError: continueRegularOutput on the HTTP node).
|
|
out.push({
|
|
json: {
|
|
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;
|