Skip to content

Commit 40d4617

Browse files
authored
Merge pull request #6 from traien/dev/add-attachments-documents
Add attachment and document operations with handlers and API requests
2 parents 029885e + 046eed3 commit 40d4617

9 files changed

Lines changed: 545 additions & 0 deletions

File tree

.vscode/tasks.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,76 @@
2929
],
3030
"group": "build"
3131
},
32+
{
33+
"label": "build:tsc",
34+
"type": "shell",
35+
"command": "npm run build",
36+
"isBackground": false,
37+
"problemMatcher": [
38+
"$tsc"
39+
],
40+
"group": "build"
41+
},
42+
{
43+
"label": "build:tsc",
44+
"type": "shell",
45+
"command": "npm run build",
46+
"isBackground": false,
47+
"problemMatcher": [
48+
"$tsc"
49+
],
50+
"group": "build"
51+
},
52+
{
53+
"label": "build:tsc",
54+
"type": "shell",
55+
"command": "npm run build",
56+
"isBackground": false,
57+
"problemMatcher": [
58+
"$tsc"
59+
],
60+
"group": "build"
61+
},
62+
{
63+
"label": "build:tsc",
64+
"type": "shell",
65+
"command": "npm run build",
66+
"isBackground": false,
67+
"problemMatcher": [
68+
"$tsc"
69+
],
70+
"group": "build"
71+
},
72+
{
73+
"label": "build:tsc",
74+
"type": "shell",
75+
"command": "npm run build",
76+
"isBackground": false,
77+
"problemMatcher": [
78+
"$tsc"
79+
],
80+
"group": "build"
81+
},
82+
{
83+
"label": "build:tsc",
84+
"type": "shell",
85+
"command": "npm run build",
86+
"isBackground": false,
87+
"problemMatcher": [
88+
"$tsc"
89+
],
90+
"group": "build"
91+
},
92+
{
93+
"label": "build:tsc",
94+
"type": "shell",
95+
"command": "npm run build",
96+
"isBackground": false,
97+
"problemMatcher": [
98+
"$tsc"
99+
],
100+
"group": "build"
101+
},
32102
{
33103
"label": "build:tsc",
34104
"type": "shell",

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,47 @@ The Dynamic resource allows you to work with any entity type in your EspoCRM sys
149149
}
150150
```
151151

152+
### Attachments & Documents
153+
154+
You can upload files to EspoCRM as `Attachment` records and then create a `Document` that references the uploaded file. You can also download attachments to binary output in n8n.
155+
156+
1) Upload an Attachment
157+
158+
- Add an `EspoCRM` node
159+
- Resource: `Attachment`
160+
- Operation: `Upload`
161+
- Fields:
162+
- `Binary Property`: name of the binary key on the incoming item (default `data`)
163+
- `Related Type`: usually `Document` (but can be any entity supported by your instance)
164+
- `Field`: usually `file` for Document’s File field
165+
- `Role`: defaults to `Attachment` (other roles: Inline Attachment)
166+
- The node derives `name`, `type`, and `size` from the binary; you can override `name` and `type` in Additional Fields
167+
- Output contains the created Attachment `id`
168+
169+
2) Create a Document linked to the uploaded file
170+
171+
- Add a second `EspoCRM` node
172+
- Resource: `Document`
173+
- Operation: `Create`
174+
- Fields:
175+
- `Name`: document name
176+
- `File ID`: reference the Attachment `id` (from the previous step)
177+
- Optional: `Publish Date` (date only is expected; we normalize inputs), `Status`, `File Name`, `Folder ID`, `Description`, `Assigned User ID`
178+
179+
3) Download an Attachment
180+
181+
- Add an `EspoCRM` node
182+
- Resource: `Attachment`
183+
- Operation: `Download`
184+
- Fields:
185+
- `Attachment ID`: the Attachment record ID
186+
- `Binary Property`: output key to store the file (default `data`)
187+
- Output: one item with `binary[Binary Property]` populated, including `fileName` and `mimeType`
188+
189+
Notes:
190+
- EspoCRM may restrict allowed file types by extension/MIME. If you receive `403 Not allowed file type`, verify your instance settings and the file’s extension/MIME.
191+
- For attachment-multiple fields (e.g., `Note.attachments`), upload first, then create/update the parent entity with `attachmentsIds` including the returned attachment ID (remember to include existing IDs when updating to avoid unlinking).
192+
152193
## Resources
153194

154195
- [EspoCRM API Documentation](https://docs.espocrm.com/development/api/)

nodes/EspoCRM/EspoCRM.node.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { meetingOperations, meetingFields } from './operations/meeting/meeting.o
1818
import { taskOperations, taskFields } from './operations/task/task.operations';
1919
import { callOperations, callFields } from './operations/call/call.operations';
2020
import { opportunityOperations, opportunityFields } from './operations/opportunity/opportunity.operations';
21+
import { attachmentOperations, attachmentFields } from './operations/attachment/attachment.operations';
22+
import { documentOperations, documentFields } from './operations/document/document.operations';
2123
import { caseOperations, caseFields } from './operations/case/case.operations';
2224

2325
// Import handler factory
@@ -98,6 +100,14 @@ export class EspoCRM implements INodeType {
98100
name: 'Case',
99101
value: 'case',
100102
},
103+
{
104+
name: 'Attachment',
105+
value: 'attachment',
106+
},
107+
{
108+
name: 'Document',
109+
value: 'document',
110+
},
101111
],
102112
default: 'contact',
103113
},
@@ -136,6 +146,10 @@ export class EspoCRM implements INodeType {
136146
...opportunityFields,
137147
...caseOperations,
138148
...caseFields,
149+
...attachmentOperations,
150+
...attachmentFields,
151+
...documentOperations,
152+
...documentFields,
139153
...dynamicOperations,
140154
...dynamicFields,
141155

@@ -541,6 +555,15 @@ export class EspoCRM implements INodeType {
541555
// Execute the operation using the handler
542556
let responseData: IDataObject | IDataObject[];
543557

558+
559+
560+
// Special-case: map attachment upload -> create
561+
if (resource === 'attachment' && operation === 'upload') {
562+
responseData = await handler.create.call(this, i);
563+
returnData.push(responseData as IDataObject);
564+
break;
565+
}
566+
544567
switch (operation) {
545568
case 'create':
546569
responseData = await handler.create.call(this, i);

nodes/EspoCRM/GenericFunctions.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,96 @@ export async function espoApiRequestAllItems(
117117

118118
return returnData;
119119
}
120+
121+
/**
122+
* Make an API request expecting binary (arraybuffer) response
123+
*/
124+
export async function espoApiRequestBinary(
125+
this: IFunctions,
126+
method: IHttpRequestMethods,
127+
endpoint: string,
128+
qs: IDataObject = {},
129+
uri?: string,
130+
headers: IDataObject = {},
131+
): Promise<{ data: Buffer; headers: IDataObject }>
132+
{
133+
const credentials = await this.getCredentials('espoCRMApi') as {
134+
baseUrl: string;
135+
authType: string;
136+
apiKey: string;
137+
secretKey: string;
138+
};
139+
140+
const options: IHttpRequestOptions = {
141+
headers: {
142+
'Accept': 'application/octet-stream,*/*',
143+
...headers,
144+
},
145+
method,
146+
url: uri ?? endpoint,
147+
qs,
148+
// Request binary by disabling encoding; n8n returns Buffer when encoding is null
149+
encoding: null as any,
150+
};
151+
152+
if (credentials.authType === 'hmac' && credentials.secretKey) {
153+
const hmacString = method + ' ' + endpoint;
154+
const hmac = crypto.createHmac('sha256', credentials.secretKey);
155+
hmac.update(hmacString);
156+
const signature = hmac.digest('base64');
157+
const authPart = Buffer.from(credentials.apiKey + ':').toString('base64') + signature;
158+
options.headers!['X-Hmac-Authorization'] = authPart;
159+
} else {
160+
options.headers!['X-Api-Key'] = credentials.apiKey;
161+
}
162+
163+
this.logger.debug('EspoCRM API binary request options:', options);
164+
165+
try {
166+
const response = await this.helpers.httpRequest({
167+
baseURL: `${credentials.baseUrl}/api/v1`,
168+
...options,
169+
returnFullResponse: true,
170+
});
171+
// Coerce body to Buffer for n8n binary helpers
172+
const resAny = response as any;
173+
let body: any = resAny.body ?? resAny.data;
174+
let buffer: Buffer;
175+
if (Buffer.isBuffer(body)) {
176+
buffer = body;
177+
} else if (body && typeof body.on === 'function') {
178+
// Node.js Readable stream
179+
buffer = await new Promise<Buffer>((resolve, reject) => {
180+
const chunks: Buffer[] = [];
181+
body.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
182+
body.on('end', () => resolve(Buffer.concat(chunks)));
183+
body.on('error', (err: Error) => reject(err));
184+
});
185+
} else if (typeof body === 'string') {
186+
buffer = Buffer.from(body);
187+
} else if (body == null) {
188+
buffer = Buffer.alloc(0);
189+
} else {
190+
// Fallback attempt
191+
try { buffer = Buffer.from(body); } catch {
192+
throw new NodeOperationError(this.getNode(), 'Unexpected binary response type from EspoCRM');
193+
}
194+
}
195+
return { data: buffer, headers: (response.headers || {}) as IDataObject };
196+
} catch (error) {
197+
this.logger.debug('EspoCRM API binary error message:', error.message);
198+
if (error.response) {
199+
this.logger.debug('EspoCRM API binary error response body:', error.response.body || error.response.data);
200+
}
201+
if (error.response) {
202+
const errorMessage = (error.response.body && error.response.body.message) || error.message;
203+
const statusCode = error.statusCode;
204+
const statusReason = (error.response.headers && error.response.headers['x-status-reason']) || '';
205+
throw new NodeOperationError(
206+
this.getNode(),
207+
`EspoCRM API error: ${errorMessage}. Status: ${statusCode}${statusReason ? `. Reason: ${statusReason}` : ''}`,
208+
);
209+
}
210+
throw error;
211+
}
212+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { IExecuteFunctions, IDataObject, NodeOperationError } from 'n8n-workflow';
2+
import { EntityHandler } from './EntityHandler';
3+
import { espoApiRequest, espoApiRequestAllItems } from '../GenericFunctions';
4+
5+
export class AttachmentHandler implements EntityHandler {
6+
async create(this: IExecuteFunctions, index: number): Promise<IDataObject> {
7+
// This method will be used for "upload" operation mapped to create
8+
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', index) as string;
9+
const item = this.getInputData()[index];
10+
if (!item.binary || !item.binary[binaryPropertyName]) {
11+
throw new NodeOperationError(this.getNode(), `No binary data property "${binaryPropertyName}" exists on item!`);
12+
}
13+
const binary = item.binary[binaryPropertyName];
14+
15+
const role = (this.getNodeParameter('role', index) as string) || 'Attachment';
16+
const relatedType = (this.getNodeParameter('relatedType', index) as string) || 'Document';
17+
const field = (this.getNodeParameter('field', index) as string) || 'file';
18+
const additionalFields = this.getNodeParameter('additionalFields', index, {}) as IDataObject;
19+
20+
const nameFromBinary = binary.fileName || (additionalFields.name as string) || 'file';
21+
// Try to use binary mime, fallback to guess by extension, then default
22+
const guessMimeByExt = (fileName?: string): string | undefined => {
23+
if (!fileName) return undefined;
24+
const ext = fileName.split('.').pop()?.toLowerCase();
25+
switch (ext) {
26+
case 'pdf': return 'application/pdf';
27+
case 'txt': return 'text/plain';
28+
case 'csv': return 'text/csv';
29+
case 'json': return 'application/json';
30+
case 'jpg':
31+
case 'jpeg': return 'image/jpeg';
32+
case 'png': return 'image/png';
33+
case 'gif': return 'image/gif';
34+
case 'doc': return 'application/msword';
35+
case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
36+
case 'xls': return 'application/vnd.ms-excel';
37+
case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
38+
default: return undefined;
39+
}
40+
};
41+
const typeFromBinary = binary.mimeType || (additionalFields.type as string) || guessMimeByExt(nameFromBinary) || 'application/octet-stream';
42+
// Ensure size is a number; if not present, compute from base64 length
43+
const sizeFromBinary = typeof binary.fileSize !== 'undefined'
44+
? Number(binary.fileSize)
45+
: (typeof binary.data === 'string' ? Buffer.from(binary.data, 'base64').length : undefined);
46+
47+
// Compose data: URI
48+
const dataUri = `data:${typeFromBinary};base64,${binary.data}`;
49+
50+
const body: IDataObject = {
51+
role,
52+
relatedType,
53+
field,
54+
name: nameFromBinary,
55+
type: typeFromBinary,
56+
file: dataUri,
57+
...additionalFields,
58+
};
59+
if (typeof sizeFromBinary === 'number' && !isNaN(sizeFromBinary)) {
60+
body.size = sizeFromBinary;
61+
}
62+
63+
const response = await espoApiRequest.call(this, 'POST', '/Attachment', body);
64+
return response as IDataObject;
65+
}
66+
67+
async get(this: IExecuteFunctions, index: number): Promise<IDataObject> {
68+
const id = this.getNodeParameter('attachmentId', index) as string;
69+
const response = await espoApiRequest.call(this, 'GET', `/Attachment/${id}`);
70+
return response as IDataObject;
71+
}
72+
73+
async update(this: IExecuteFunctions, index: number): Promise<IDataObject> {
74+
// Read a parameter to avoid unused parameter warning
75+
void this.getNodeParameter('attachmentId', index, '');
76+
throw new NodeOperationError(this.getNode(), 'Update operation is not supported for Attachment');
77+
}
78+
79+
async delete(this: IExecuteFunctions, index: number): Promise<IDataObject> {
80+
const id = this.getNodeParameter('attachmentId', index) as string;
81+
await espoApiRequest.call(this, 'DELETE', `/Attachment/${id}`);
82+
return { success: true, entityType: 'attachment', id } as IDataObject;
83+
}
84+
85+
async getAll(this: IExecuteFunctions, index: number): Promise<IDataObject[]> {
86+
// Not typically used for attachments but provide minimal implementation
87+
const returnAll = this.getNodeParameter('returnAll', index, false) as boolean;
88+
const qs: IDataObject = {};
89+
if (returnAll) {
90+
return await espoApiRequestAllItems.call(this, 'GET', '/Attachment', {}, qs);
91+
} else {
92+
const limit = this.getNodeParameter('limit', index, 50) as number;
93+
qs.maxSize = limit;
94+
const response = await espoApiRequest.call(this, 'GET', '/Attachment', {}, qs);
95+
return response.list as IDataObject[];
96+
}
97+
}
98+
99+
// Custom: Download operation - not part of EntityHandler interface, so handled in node execute routing or via getAll mapping
100+
async download(this: IExecuteFunctions, index: number): Promise<IDataObject> {
101+
throw new NodeOperationError(this.getNode(), 'Attachment download is temporarily disabled');
102+
}
103+
}

0 commit comments

Comments
 (0)