/** * Attach a file to an iDempiere record via the standard Strapi flow (the same * mechanism AttachmentModal.vue / mobile tasks / product galleries use): * 1. resolve the AD_Table_ID from the table name, * 2. find or create the ad-attachment record (AD_Table_ID + Record_ID), * 3. upload the bytes to Strapi (/upload). * Returns the Strapi attachment id + the uploaded file URL. * * Reused by the inbox upload (attach to CUST_IncomingInvoice) and confirm * (attach to the created C_Invoice). */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' import strapiHelper from '../strapiHelper' // Frontend attachment table ids (mirror server/api/attachments/[table]/[id]/index.post.ts). // PO_Invoice (3182) and Lead_User (1142) are NOT real iDempiere tables, so the ad_table // lookup misses and we need this fallback. Lead_User is the target the lead page's // AttachmentModal filters on — quote PDFs attached with it show up there. const TABLE_ID_FALLBACK: Record = { C_Invoice: 318, PO_Invoice: 3182, C_BPartner: 291, AD_User: 114, Lead_User: 1142 } export const resolveTableId = async (event: any, token: string, tableName: string): Promise => { try { const res: any = await fetchHelper(event, `models/ad_table?$filter=${string.urlEncode(`TableName eq '${tableName}'`)}`, 'GET', token, null) if (res?.records?.[0]?.id) return res.records[0].id } catch {} return TABLE_ID_FALLBACK[tableName] || 0 } export const attachToStrapi = async (event: any, token: string, params: { tableName: string; recordId: number; recordUu?: string; buffer: Buffer; filename: string; mimeType?: string }): Promise<{ strapiAttachmentId: number; fileUrl: string }> => { const tableId = await resolveTableId(event, token, params.tableName) let strapiId: number | null = null const existing: any = await strapiHelper(event, `ad-attachments?filters[AD_Table_ID][$eq]=${tableId}&filters[Record_ID][$eq]=${params.recordId}`, 'GET', null) if (existing?.data?.[0]?.id) { strapiId = existing.data[0].id } else { const created: any = await strapiHelper(event, 'ad-attachments', 'POST', { data: { AD_Table_ID: tableId, Record_ID: params.recordId, Record_UU: params.recordUu || undefined } }) strapiId = created?.data?.id || null } if (!strapiId) throw new Error('Could not create attachment record') // NATIVE FormData + Blob (Node 18+): undici sets the multipart boundary and // streams the body correctly — exactly like curl/the browser. The `form-data` // npm package produced a body ofetch mis-sent, which Strapi rejected with a 500. // (Verified: curl to /api/upload returns 201, so the endpoint is fine and the // server-side encoding was the only bug.) field/ref/refId link the file to the // ad-attachment record so it shows on the iDempiere record. const fd: any = new FormData() fd.append('field', 'attachment') fd.append('ref', 'api::ad-attachment.ad-attachment') fd.append('refId', String(strapiId)) fd.append('files', new Blob([params.buffer], { type: params.mimeType || 'application/pdf' }), params.filename) fd.append('fileInfo', JSON.stringify({ caption: String(params.recordId), alternativeText: params.filename })) // Standard Strapi endpoint on the dedicated server-side base STRAPIUPLOAD // (config.api.strapiupload, e.g. http://127.0.0.1:1337) — NOT /media-api/upload. const config: any = useRuntimeConfig() const uploadBase = (config.api?.strapiupload || (config.public?.strapi || config.api?.strapi || '').replace(/\/media-api\/?$/, '') ).replace(/\/$/, '') let up: any try { up = await $fetch(`${uploadBase}/api/upload`, { method: 'POST', headers: { Authorization: `Bearer ${config.api?.strapitoken}` }, // undici sets multipart Content-Type body: fd }) } catch (err: any) { const detail = err?.data ?? err?.response?._data ?? err?.message console.error('[Inbox] Strapi /api/upload failed:', err?.response?.status || err?.statusCode, typeof detail === 'string' ? detail.slice(0, 600) : JSON.stringify(detail || {}).slice(0, 600)) throw err } const file = Array.isArray(up) ? up[0] : (up?.[0] ?? up) const fileUrl = file?.url || '' return { strapiAttachmentId: strapiId, fileUrl } } /** * Fetch the bytes of a previously-uploaded Strapi file (by its stored url). * * The stored url is relative (`/uploads/xxx.pdf`). nginx does NOT proxy `/uploads` * (and the public host is the Nuxt app → 404 + Vue Router warnings), so we must * hit a base that actually serves the file. Topology (per nginx): files-api → :80/, * media-api → :1337/api, Strapi core on :1337. We try the known-good internal * routes in order until one returns bytes — robust without hard-coding one host. */ export const fetchStrapiFileBytes = async (event: any, fileUrl: string): Promise => { if (!fileUrl) return null const config: any = useRuntimeConfig() const auth = { Authorization: 'Bearer ' + config.api.strapitoken } // Already absolute → fetch directly. if (/^https?:\/\//i.test(fileUrl)) { try { return Buffer.from(await $fetch(fileUrl, { responseType: 'arrayBuffer', headers: auth })) } catch { return null } } const path = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl const pub = String(config.public?.strapi || '').replace(/\/$/, '') const candidates = [ config.api?.strapiupload, // Strapi core direct (e.g. http://127.0.0.1:1337) — local provider serves /uploads 'http://127.0.0.1:80', // files-api proxies to :80/ — uploads served here pub ? pub + '/files-api' : null // public files-api route (last resort; goes through nginx) ].filter(Boolean).map((b: string) => String(b).replace(/\/$/, '')) for (const base of candidates) { try { const ab: any = await $fetch(base + path, { responseType: 'arrayBuffer', headers: auth }) const buf = Buffer.from(ab) if (buf?.length) return buf } catch {} } console.warn('[Inbox] fetchStrapiFileBytes: file not reachable via', candidates.map(c => c + path).join(' | ')) return null } export default attachToStrapi