/** * 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>} */ 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} */ 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); } }