servchan/src/integrations/serviceChannel/attachments.js
jmcqueen 0fc3eb38fa Add /createWO, WO file upload, and suppress SC upload webhook echo.
Enable ServiceChannel work order creation from DM, upload attachments from
WO spaces via /attach and /upload, and skip note/attachment echo for
bot-originated uploads. Also fixes duplicate FedEx notifications and adds
multi-instance command dedup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:22:21 -04:00

187 lines
5.7 KiB
JavaScript

/**
* ServiceChannel work order attachment helpers (OData).
*/
import FormData from 'form-data';
import { fetchWithRetry, scAxios } from './client.js';
import { logger } from '../../utils/logger.js';
const ATTACHMENT_SELECT =
'Id,Name,Uri,Description,TimeStamp,IsInvoiceDigitalCopy,NoteId';
export function getContentTypeFromFilename(filename) {
if (!filename) return 'application/octet-stream';
const lower = filename.toLowerCase();
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.bmp')) return 'image/bmp';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.heic')) return 'image/heic';
if (lower.endsWith('.heif')) return 'image/heif';
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.doc')) return 'application/msword';
if (lower.endsWith('.docx')) {
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.xlsx')) {
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (lower.endsWith('.pptx')) {
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
}
return 'application/octet-stream';
}
/**
* List attachments for a work order.
* @returns {Promise<Array<object>>}
*/
export async function listWorkOrderAttachments(workOrderId) {
if (!workOrderId) return [];
const start = Date.now();
try {
const res = await fetchWithRetry(
`/odata/workorders(${workOrderId})/attachments`,
{
params: {
$select: ATTACHMENT_SELECT,
$orderby: 'TimeStamp desc',
},
timeout: 30000,
}
);
const attachments = res.data?.value || [];
logger(
'sc:listAttachments',
`Found ${attachments.length} for WO ${workOrderId} (${Date.now() - start}ms)`
);
return attachments;
} catch (err) {
logger('sc:listAttachments', `Failed for WO ${workOrderId}: ${err.message}`, 'warn');
return [];
}
}
/**
* Fetch a single attachment by id.
* @returns {Promise<object|null>}
*/
export async function getWorkOrderAttachment(workOrderId, attachmentId) {
if (!workOrderId || attachmentId == null) return null;
const start = Date.now();
try {
const res = await fetchWithRetry(
`/odata/workorders(${workOrderId})/attachments`,
{
params: { $filter: `Id eq ${attachmentId}` },
timeout: 30000,
}
);
const att = res.data?.value?.[0] || null;
if (att) {
logger(
'sc:getAttachment',
`Fetched attachment ${attachmentId} for WO ${workOrderId} (${Date.now() - start}ms)`
);
}
return att;
} catch (err) {
logger(
'sc:getAttachment',
`Failed ${attachmentId} for WO ${workOrderId}: ${err.message}`,
'warn'
);
return null;
}
}
/**
* Download attachment bytes from a storage URI.
* @returns {Promise<{ buffer: Buffer, contentType: string, fileName: string }>}
*/
export async function downloadAttachment(uri, fileName = 'attachment') {
if (!uri) throw new Error('Missing attachment URI');
const start = Date.now();
const safeName = fileName || 'attachment';
const fileRes = await fetch(uri);
if (!fileRes.ok) {
throw new Error(`Download failed: HTTP ${fileRes.status}`);
}
const headerType = fileRes.headers.get('content-type')?.split(';')[0]?.trim();
const contentType = (headerType && headerType !== 'application/octet-stream')
? headerType
: getContentTypeFromFilename(safeName);
const buffer = Buffer.from(await fileRes.arrayBuffer());
logger(
'sc:downloadAttachment',
`Downloaded ${safeName} (${buffer.length} bytes, ${Date.now() - start}ms)`
);
return { buffer, contentType, fileName: safeName };
}
function summarizeUploadError(err) {
const data = err?.response?.data;
if (data == null) return err.message || 'Upload failed';
if (typeof data === 'string') return data;
if (data.Message) return String(data.Message);
try {
return JSON.stringify(data);
} catch {
return err.message || 'Upload failed';
}
}
/**
* Upload a file to an existing work order.
* POST /workorders/{workOrderId}/attachments (multipart/form-data).
*
* @returns {Promise<{ id: number|null, name: string, path: string|null }>}
*/
export async function uploadWorkOrderAttachment(workOrderId, buffer, fileName, contentType) {
if (!workOrderId || !buffer?.length || !fileName) {
throw new Error('workOrderId, buffer, and fileName are required');
}
const resolvedType = contentType || getContentTypeFromFilename(fileName);
const form = new FormData();
form.append('file', buffer, { filename: fileName, contentType: resolvedType });
const start = Date.now();
try {
const res = await scAxios.post(`/workorders/${workOrderId}/attachments`, form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
timeout: 120000,
});
const att = res.data?.Attachments?.[0] || {};
logger(
'sc:uploadAttachment',
`Uploaded ${fileName} to WO ${workOrderId} (id=${att.Id ?? 'unknown'}, ${Date.now() - start}ms)`
);
return {
id: att.Id ?? att.id ?? null,
name: att.Name || fileName,
path: att.Path ?? null,
};
} catch (err) {
const msg = summarizeUploadError(err);
logger('sc:uploadAttachment', `Failed ${fileName} for WO ${workOrderId}: ${msg}`, 'error');
throw new Error(msg);
}
}