From 3df857df561eb79dcabd9ab689481d73f3c83b6c Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Sat, 30 Apr 2022 16:01:54 -0400 Subject: [PATCH] Initial commit --- .editorconfig | 17 ++ .gitignore | 8 + .nvmrc | 1 + LICENSE.md | 21 ++ README.md | 30 +++ credentials/NetSuite.credentials.ts | 61 +++++ gulpfile.js | 11 + index.js | 0 nodes/NetSuite/NetSuite.node.ts | 353 ++++++++++++++++++++++++++ nodes/NetSuite/NetSuite.node.types.ts | 16 ++ nodes/NetSuite/netSuite.svg | 1 + nodes/NetSuite/types.d.ts | 1 + package.json | 71 ++++++ tsconfig.json | 33 +++ tslint.json | 124 +++++++++ 15 files changed, 748 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 credentials/NetSuite.credentials.ts create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 nodes/NetSuite/NetSuite.node.ts create mode 100644 nodes/NetSuite/NetSuite.node.types.ts create mode 100644 nodes/NetSuite/netSuite.svg create mode 100644 nodes/NetSuite/types.d.ts create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fdda219 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[package.json] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffb419d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.DS_Store +.tmp +tmp +dist +npm-debug.log* +package-lock.json +yarn.lock \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b6a7d89 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9b21da6 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Nicholas Penree + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cd22ef --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# n8n-nodes-netsuite + +![n8n.io - Workflow Automation](https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-logo.png) + +n8n node for NetSuite using the REST API. + + +## License + +MIT License + +Copyright (c) 2022 Nicholas Penree + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/credentials/NetSuite.credentials.ts b/credentials/NetSuite.credentials.ts new file mode 100644 index 0000000..c710443 --- /dev/null +++ b/credentials/NetSuite.credentials.ts @@ -0,0 +1,61 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class NetSuite implements ICredentialType { + name = 'netsuite'; + displayName = 'NetSuite'; + documentationUrl = 'netsuite'; + properties: INodeProperties[] = [ + { + displayName: 'Hostname', + name: 'hostname', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Account ID', + name: 'accountId', + type: 'string', + default: '', + required: true, + description: 'NetSuite Account ID', + }, + { + displayName: 'Consumer Key', + name: 'consumerKey', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Consumer Secret', + name: 'consumerSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + required: true, + }, + { + displayName: 'Token Key', + name: 'tokenKey', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Token Secret', + name: 'tokenSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + required: true, + }, + ]; +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..546bb43 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,11 @@ +const { src, dest } = require('gulp'); + +function copyIcons() { + src('nodes/**/*.{png,svg}') + .pipe(dest('dist/nodes')) + + return src('credentials/**/*.{png,svg}') + .pipe(dest('dist/credentials')); +} + +exports.default = copyIcons; \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/nodes/NetSuite/NetSuite.node.ts b/nodes/NetSuite/NetSuite.node.ts new file mode 100644 index 0000000..74bed83 --- /dev/null +++ b/nodes/NetSuite/NetSuite.node.ts @@ -0,0 +1,353 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + IRunExecutionData, + NodeApiError, + LoggerProxy as Logger, +} from 'n8n-workflow'; +import { + INetSuiteCredentials, INetSuiteOperationOptions, +} from './NetSuite.node.types'; + +import { makeRequest } from '@fye/netsuite-rest-api'; +import { fsync } from 'fs'; +import { connected } from 'process'; + +const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) { + // console.log(`Netsuite response:`, response.body); + let body: any = {}; + const { + title: webTitle = undefined, + code: restletCode = undefined, + 'o:errorCode': webCode, + 'o:errorDetails': webDetails, + message: restletMessage = undefined, + } = response.body; + if (!(response.statusCode && response.statusCode >= 200 && response.statusCode < 400)) { + let message = webTitle || restletMessage || webCode || response.statusText; + if (webDetails && webDetails.length > 0) { + message = webDetails[0].detail || message; + } + if (fns.continueOnFail() !== true) { + const code = webCode || restletCode; + const error = new Error(code); + error.message = message; + throw error; + } else { + body = { + error: message, + }; + } + } else { + body = response.body; + } + return { json: body }; +}; + +const getConfig = (credentials: INetSuiteCredentials) => ({ + netsuiteApiHost: credentials.hostname, + consumerKey: credentials.consumerKey, + consumerSecret: credentials.consumerSecret, + netsuiteAccountId: credentials.accountId, + netsuiteTokenKey: credentials.tokenKey, + netsuiteTokenSecret: credentials.tokenSecret, +}); + +export class NetSuite implements INodeType { + description: INodeTypeDescription = { + displayName: 'NetSuite', + name: 'netsuite', + group: ['netsuite', 'erp'], + version: 1, + description: 'NetSuite REST API', + defaults: { + name: 'NetSuite', + color: '#125580', + }, + icon: 'file:netSuite.svg', + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'netsuite', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get Records', + value: 'getRecords', + }, + { + name: 'Get Record', + value: 'getRecord', + }, + { + name: 'Update Record', + value: 'updateRecord', + }, + { + name: 'Delete Record', + value: 'deleteRecord', + }, + { + name: 'Execute SuiteQL', + value: 'runSuiteQL', + }, + { + name: 'Get Workbook', + value: 'getWorkbook', + }, + { + name: 'Raw Request', + value: 'rawRequest', + }, + ], + default: 'getRecords', + }, + { + displayName: 'Record Type', + name: 'recordType', + type: 'options', + options: [ + { name: 'Assembly Item', value: 'assemblyItem' }, + { name: 'Billing Account', value: 'billingAccount' }, + { name: 'Calendar Event', value: 'calendarEvent' }, + { name: 'Cash Sale', value: 'cashSale' }, + { name: 'Charge', value: 'charge' }, + { name: 'Contact', value: 'contact' }, + { name: 'Contact Category', value: 'contactCategory' }, + { name: 'Contact Role', value: 'contactRole' }, + { name: 'Credit Memo', value: 'creditMemo' }, + { name: 'Customer', value: 'customer' }, + { name: 'Customer Subsidiary Relationship', value: 'customerSubsidiaryRelationship' }, + { name: 'Email Template', value: 'emailTemplate' }, + { name: 'Employee', value: 'employee' }, + { name: 'Inventory Item', value: 'inventoryItem' }, + { name: 'Invoice', value: 'invoice' }, + { name: 'Item Fulfillment', value: 'itemFulfillment' }, + { name: 'Journal Entry', value: 'journalEntry' }, + { name: 'Message', value: 'message' }, + { name: 'Non-Inventory Sale Item', value: 'nonInventorySaleItem' }, + { name: 'Phone Call', value: 'phoneCall' }, + { name: 'Price Book', value: 'priceBook' }, + { name: 'Price Plan', value: 'pricePlan' }, + { name: 'Purchase Order', value: 'purchaseOrder' }, + { name: 'Sales Order', value: 'salesOrder' }, + { name: 'Subscription', value: 'subscription' }, + { name: 'Subscription Change Order', value: 'subscriptionChangeOrder' }, + { name: 'Subscription Line', value: 'subscriptionLine' }, + { name: 'Subscription Plan', value: 'subscriptionPlan' }, + { name: 'Subscription Term', value: 'subscriptionTerm' }, + { name: 'Subsidiary', value: 'subsidiary' }, + { name: 'Task', value: 'task' }, + { name: 'Time Bill', value: 'timeBill' }, + { name: 'Usage', value: 'usage' }, + { name: 'Vendor', value: 'vendor' }, + { name: 'Vendor Bill', value: 'vendorBill' }, + { name: 'Vendor Subsidiary Relationship', value: 'vendorSubsidiaryRelationship' }, + ], + displayOptions: { + show: { + operation: [ + 'getRecord', + 'updateRecord', + 'deleteRecord', + 'getRecords', + ], + }, + }, + default: 'salesOrder', + }, + { + displayName: 'ID', + name: 'internalId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'The internal identifier of the record. Prefix with eid: to use the external identifier.', + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'getRecords', + 'runSuiteQL', + 'rawRequest', + ], + }, + }, + }, + { + displayName: 'Restrict Returned Fields', + name: 'fields', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'Optionally return only the specified fields and sublists in the response.', + }, + { + displayName: 'Expand Sub-resources', + name: 'expandSubResources', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'If true, automatically expands all sublists, sublist lines, and subrecords on this record.', + }, + { + displayName: 'Simple Enum Format', + name: 'simpleEnumFormat', + type: 'boolean', + required: true, + // eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-simplify + default: false, + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'If true, returns enumeration values in a format that only shows the internal ID value.', + }, + { + displayName: 'API Version', + name: 'version', + type: 'options', + options: [ + { + name: 'v1', + value: 'v1', + }, + ], + displayOptions: { + show: { + operation: [ + 'getRecord', + 'getRecords', + 'updateRecord', + 'deleteRecord', + ], + }, + }, + default: 'v1', + }, + ], + }; + + static async getRecords(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex } = options; + const apiVersion = fns.getNodeParameter('version', itemIndex) as string; + const recordType = fns.getNodeParameter('recordType', itemIndex) as string; + const queryLimit = fns.getNodeParameter('queryLimit', itemIndex, 10) as string; + let hasMore = true; + let nextUrl = `services/rest/record/${apiVersion}/${recordType}?limit=${queryLimit}&offset=0`; + let method = 'POST'; + let requestType = 'suiteql'; + const requestData = { + method: 'GET', + requestType: 'record', + nextUrl, + }; + const returnData: INodeExecutionData[] = []; + + while (hasMore === true) { + const response = await makeRequest(getConfig(credentials), { + method, + requestType, + nextUrl, + }); + const body: any = handleNetsuiteResponse(fns, response); + const { hasMore: doContinue, items, links } = body; + if (doContinue) { + nextUrl = links.find((link: any) => link.rel === 'next').href; + } + if (Array.isArray(items)) { + returnData.push(...items.map(item => ({ json: item }))); + } + hasMore = doContinue; + } + return returnData; + } + + static async getRecord(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex } = options; + const params = new URLSearchParams(); + const expandSubResources = fns.getNodeParameter('expandSubResources', itemIndex) as boolean; + const simpleEnumFormat = fns.getNodeParameter('simpleEnumFormat', itemIndex) as boolean; + const apiVersion = fns.getNodeParameter('version', itemIndex) as string; + const recordType = fns.getNodeParameter('recordType', itemIndex) as string; + const internalId = fns.getNodeParameter('internalId', itemIndex) as string; + + if (expandSubResources) { + params.append('expandSubResources', 'true'); + } + if (simpleEnumFormat) { + params.append('simpleEnumFormat', 'true'); + } + const q = params.toString(); + const requestData = { + method: 'GET', + requestType: 'record', + path: `services/rest/record/${apiVersion}/${recordType}/${internalId}${q ? `?${q}` : ''}`, + }; + const response = await makeRequest(getConfig(credentials), requestData); + return handleNetsuiteResponse(fns, response); + } + + async execute(this: IExecuteFunctions): Promise { + const credentials: INetSuiteCredentials = (await this.getCredentials('netsuite')) as INetSuiteCredentials; + const operation = this.getNodeParameter('operation', 0) as string; + let items: INodeExecutionData[] = this.getInputData(); + let returnData: INodeExecutionData[] = []; + + for (let itemIndex: number = 0; itemIndex < items.length; itemIndex++) { + console.log(`Processing ${operation} for ${itemIndex+1} of ${items.length}`); + const item: INodeExecutionData = items[itemIndex]; + if (operation === 'getRecord') { + const record = await NetSuite.getRecord({fns: this, credentials, itemIndex}); + record.json.orderNo = item.json.orderNo; + returnData.push(record); + } else if (operation == 'getRecords') { + const records = await NetSuite.getRecords({fns: this, credentials, itemIndex}); + returnData.push(...records); + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/nodes/NetSuite/NetSuite.node.types.ts b/nodes/NetSuite/NetSuite.node.types.ts new file mode 100644 index 0000000..b6236fb --- /dev/null +++ b/nodes/NetSuite/NetSuite.node.types.ts @@ -0,0 +1,16 @@ +import { IExecuteFunctions } from "n8n-core"; + +export type INetSuiteCredentials = { + hostname: string; + accountId: string; + consumerKey: string; + consumerSecret: string; + tokenKey: string; + tokenSecret: string; +}; + +export type INetSuiteOperationOptions = { + fns: IExecuteFunctions; + credentials: INetSuiteCredentials; + itemIndex: number; +} \ No newline at end of file diff --git a/nodes/NetSuite/netSuite.svg b/nodes/NetSuite/netSuite.svg new file mode 100644 index 0000000..5d67263 --- /dev/null +++ b/nodes/NetSuite/netSuite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nodes/NetSuite/types.d.ts b/nodes/NetSuite/types.d.ts new file mode 100644 index 0000000..58e1e41 --- /dev/null +++ b/nodes/NetSuite/types.d.ts @@ -0,0 +1 @@ +declare module '@fye/netsuite-rest-api'; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d438d7 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "n8n-nodes-netsuite", + "version": "0.2.0", + "description": "n8n node for NetSuite using the REST API", + "license": "MIT", + "homepage": "https://github.com/drudge/n8n-nodes-netsuite", + "author": { + "name": "Nicholas Penree", + "email": "nick@penree.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/drudge/n8n-nodes-netsuite.git" + }, + "main": "index.js", + "scripts": { + "dev": "npm run watch", + "build": "tsc && gulp", + "lint": "tslint -p tsconfig.json -c tslint.json", + "lintfix": "tslint --fix -p tsconfig.json -c tslint.json", + "nodelinter": "nodelinter", + "watch": "tsc --watch", + "test": "jest" + }, + "files": [ + "dist" + ], + "n8n": { + "credentials": [ + "dist/credentials/NetSuite.credentials.js" + ], + "nodes": [ + "dist/nodes/NetSuite/NetSuite.node.js" + ] + }, + "devDependencies": { + "@types/express": "^4.17.6", + "@types/jest": "^26.0.13", + "@types/node": "^14.17.27", + "@types/request-promise-native": "~1.0.15", + "gulp": "^4.0.0", + "jest": "^26.4.2", + "n8n": "^0.174.0", + "n8n-workflow": "~0.83.0", + "nodelinter": "^0.1.9", + "ts-jest": "^26.3.0", + "tslint": "^6.1.2", + "typescript": "~4.3.5" + }, + "dependencies": { + "@fye/netsuite-rest-api": "^2.0.0", + "n8n-core": "~0.101.0" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testURL": "http://localhost/", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": [ + "/dist/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "json" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6456490 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "lib": [ + "es2017", + "es2019.array" + ], + "types": [ + "node", + "jest" + ], + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "strictNullChecks": true, + "strict": true, + "preserveConstEnums": true, + "resolveJsonModule": true, + "declaration": true, + "outDir": "./dist/", + "target": "es2017", + "sourceMap": true + }, + "include": [ + "credentials/**/*", + "src/**/*", + "nodes/**/*", + "nodes/**/*.json", + "test/**/*" + ], + "exclude": [ + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..859893d --- /dev/null +++ b/tslint.json @@ -0,0 +1,124 @@ +{ + "linterOptions": { + "exclude": [ + "node_modules/**/*" + ] + }, + "defaultSeverity": "error", + "jsRules": {}, + "rules": { + "array-type": [ + true, + "array-simple" + ], + "arrow-return-shorthand": true, + "ban": [ + true, + { + "name": "Array", + "message": "tsstyle#array-constructor" + } + ], + "ban-types": [ + true, + [ + "Object", + "Use {} instead." + ], + [ + "String", + "Use 'string' instead." + ], + [ + "Number", + "Use 'number' instead." + ], + [ + "Boolean", + "Use 'boolean' instead." + ] + ], + "class-name": true, + "curly": [ + true, + "ignore-same-line" + ], + "forin": true, + "jsdoc-format": true, + "label-position": true, + "indent": [true, "tabs", 2], + "member-access": [ + true, + "no-public" + ], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-any": true, + "no-arg": true, + "no-conditional-assignment": true, + "no-construct": true, + "no-debugger": true, + "no-default-export": true, + "no-duplicate-variable": true, + "no-inferrable-types": true, + "ordered-imports": [true, { + "import-sources-order": "any", + "named-imports-order": "case-insensitive" + }], + "no-namespace": [ + true, + "allow-declarations" + ], + "no-reference": true, + "no-string-throw": true, + "no-unused-expression": true, + "no-var-keyword": true, + "object-literal-shorthand": true, + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "prefer-const": true, + "radix": true, + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "switch-default": true, + "trailing-comma": [ + true, + { + "multiline": { + "objects": "always", + "arrays": "always", + "functions": "always", + "typeLiterals": "ignore" + }, + "esSpecCompliant": true + } + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "use-isnan": true, + "quotemark": [ + true, + "single" + ], + "quotes": [ + "error", + "single" + ], + "variable-name": [ + true, + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore" + ] + }, + "rulesDirectory": [] +} \ No newline at end of file