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>
187 lines
5.7 KiB
JavaScript
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);
|
|
}
|
|
}
|