From b491ed99cea87ea0f3e5ea90baf85c388fa65cb4 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:20:29 +0200 Subject: [PATCH] feat(Beeminder Node): Update Beeminder node to include all resources and operations (#17713) --- .../credentials/BeeminderApi.credentials.ts | 8 +- .../BeeminderOAuth2Api.credentials.ts | 52 + .../Beeminder/Beeminder.node.functions.ts | 219 ++- .../nodes/Beeminder/Beeminder.node.ts | 1321 ++++++++++++++++- .../nodes/Beeminder/GenericFunctions.ts | 52 +- .../test/Beeminder.node.functions.test.ts | 669 +++++++++ .../Beeminder/test/Beeminder.node.test.ts | 124 ++ .../nodes/Beeminder/test/workflow.json | 238 +++ packages/nodes-base/package.json | 1 + packages/nodes-base/utils/types.ts | 94 ++ 10 files changed, 2674 insertions(+), 104 deletions(-) create mode 100644 packages/nodes-base/credentials/BeeminderOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Beeminder/test/Beeminder.node.functions.test.ts create mode 100644 packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts create mode 100644 packages/nodes-base/nodes/Beeminder/test/workflow.json create mode 100644 packages/nodes-base/utils/types.ts diff --git a/packages/nodes-base/credentials/BeeminderApi.credentials.ts b/packages/nodes-base/credentials/BeeminderApi.credentials.ts index dcdfb0b4e2..fffcbfa588 100644 --- a/packages/nodes-base/credentials/BeeminderApi.credentials.ts +++ b/packages/nodes-base/credentials/BeeminderApi.credentials.ts @@ -13,12 +13,6 @@ export class BeeminderApi implements ICredentialType { documentationUrl = 'beeminder'; properties: INodeProperties[] = [ - { - displayName: 'User', - name: 'user', - type: 'string', - default: '', - }, { displayName: 'Auth Token', name: 'authToken', @@ -40,7 +34,7 @@ export class BeeminderApi implements ICredentialType { test: ICredentialTestRequest = { request: { baseURL: 'https://www.beeminder.com/api/v1', - url: '=/users/{{$credentials.user}}.json', + url: '/users/me.json', }, }; } diff --git a/packages/nodes-base/credentials/BeeminderOAuth2Api.credentials.ts b/packages/nodes-base/credentials/BeeminderOAuth2Api.credentials.ts new file mode 100644 index 0000000000..c94f9a666a --- /dev/null +++ b/packages/nodes-base/credentials/BeeminderOAuth2Api.credentials.ts @@ -0,0 +1,52 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class BeeminderOAuth2Api implements ICredentialType { + name = 'beeminderOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Beeminder OAuth2 API'; + + documentationUrl = 'beeminder'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://www.beeminder.com/apps/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://www.beeminder.com/apps/authorize', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: 'response_type=token', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'body', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Beeminder/Beeminder.node.functions.ts b/packages/nodes-base/nodes/Beeminder/Beeminder.node.functions.ts index 90e058068a..cbb3576953 100644 --- a/packages/nodes-base/nodes/Beeminder/Beeminder.node.functions.ts +++ b/packages/nodes-base/nodes/Beeminder/Beeminder.node.functions.ts @@ -1,31 +1,40 @@ import type { IExecuteFunctions, ILoadOptionsFunctions, - IDataObject, IHookFunctions, IWebhookFunctions, } from 'n8n-workflow'; import { beeminderApiRequest, beeminderApiRequestAllItems } from './GenericFunctions'; +export interface Datapoint { + timestamp: number; + value: number; + comment?: string; + requestid?: string; + daystamp?: string; +} + export async function createDatapoint( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, - data: IDataObject, + data: { + goalName: string; + value: number; + timestamp?: number; + comment?: string; + requestid?: string; + }, ) { - const credentials = await this.getCredentials('beeminderApi'); + const endpoint = `/users/me/goals/${data.goalName}/datapoints.json`; - const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints.json`; - - return await beeminderApiRequest.call(this, 'POST', endpoint, data); + return await beeminderApiRequest.call(this, 'POST', endpoint, data, {}, true); } export async function getAllDatapoints( this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, - data: IDataObject, + data: { goalName: string; count?: number; sort?: string; page?: number; per?: number }, ) { - const credentials = await this.getCredentials('beeminderApi'); - - const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints.json`; + const endpoint = `/users/me/goals/${data.goalName}/datapoints.json`; if (data.count !== undefined) { return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data); @@ -36,22 +45,194 @@ export async function getAllDatapoints( export async function updateDatapoint( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, - data: IDataObject, + data: { + goalName: string; + datapointId: string; + value?: number; + comment?: string; + timestamp?: number; + }, ) { - const credentials = await this.getCredentials('beeminderApi'); + const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`; - const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints/${data.datapointId}.json`; - - return await beeminderApiRequest.call(this, 'PUT', endpoint, data); + return await beeminderApiRequest.call(this, 'PUT', endpoint, data, {}, true); } export async function deleteDatapoint( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, - data: IDataObject, + data: { goalName: string; datapointId: string }, ) { - const credentials = await this.getCredentials('beeminderApi'); - - const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints/${data.datapointId}.json`; + const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`; return await beeminderApiRequest.call(this, 'DELETE', endpoint); } + +export async function createCharge( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { amount: number; note?: string; dryrun?: boolean }, +) { + const endpoint = '/charges.json'; + + const body = { + user_id: 'me', + amount: data.amount, + ...(data.note && { note: data.note }), + ...(data.dryrun && { dryrun: data.dryrun }), + }; + + return await beeminderApiRequest.call(this, 'POST', endpoint, body, {}, true); +} + +export async function uncleGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/uncleme.json`; + + return await beeminderApiRequest.call(this, 'POST', endpoint); +} + +export async function createAllDatapoints( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string; datapoints: Datapoint[] }, +) { + const endpoint = `/users/me/goals/${data.goalName}/datapoints/create_all.json`; + + const body = { + datapoints: data.datapoints, + }; + + return await beeminderApiRequest.call(this, 'POST', endpoint, body, {}, true); +} + +export async function getSingleDatapoint( + this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string; datapointId: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`; + + return await beeminderApiRequest.call(this, 'GET', endpoint); +} + +// Goal Operations +export async function getGoal( + this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string; datapoints?: boolean; emaciated?: boolean }, +) { + const endpoint = `/users/me/goals/${data.goalName}.json`; + + return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data); +} + +export async function getAllGoals( + this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, + data?: { emaciated?: boolean }, +) { + const endpoint = '/users/me/goals.json'; + + return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data || {}); +} + +export async function getArchivedGoals( + this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, + data?: { emaciated?: boolean }, +) { + const endpoint = '/users/me/goals/archived.json'; + + return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data || {}); +} + +export async function createGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { + slug: string; + title: string; + goal_type: string; + gunits: string; + goaldate?: number; + goalval?: number; + rate?: number; + initval?: number; + secret?: boolean; + datapublic?: boolean; + datasource?: string; + dryrun?: boolean; + tags?: string[]; + }, +) { + const endpoint = '/users/me/goals.json'; + + return await beeminderApiRequest.call(this, 'POST', endpoint, data, {}, true); +} + +export async function updateGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { + goalName: string; + title?: string; + yaxis?: string; + tmin?: string; + tmax?: string; + secret?: boolean; + datapublic?: boolean; + roadall?: object; + datasource?: string; + tags?: string[]; + }, +) { + const endpoint = `/users/me/goals/${data.goalName}.json`; + + return await beeminderApiRequest.call(this, 'PUT', endpoint, data, {}, true); +} + +export async function refreshGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/refresh_graph.json`; + + return await beeminderApiRequest.call(this, 'GET', endpoint); +} + +export async function shortCircuitGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/shortcircuit.json`; + + return await beeminderApiRequest.call(this, 'POST', endpoint); +} + +export async function stepDownGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/stepdown.json`; + + return await beeminderApiRequest.call(this, 'POST', endpoint); +} + +export async function cancelStepDownGoal( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { goalName: string }, +) { + const endpoint = `/users/me/goals/${data.goalName}/cancel_stepdown.json`; + + return await beeminderApiRequest.call(this, 'POST', endpoint); +} + +// User Operations +export async function getUser( + this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions, + data: { + associations?: boolean; + diff_since?: number; + skinny?: boolean; + emaciated?: boolean; + datapoints_count?: number; + }, +) { + const endpoint = '/users/me.json'; + + return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data); +} diff --git a/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts b/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts index 6022ee6524..684e8909aa 100644 --- a/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts +++ b/packages/nodes-base/nodes/Beeminder/Beeminder.node.ts @@ -1,23 +1,45 @@ import moment from 'moment-timezone'; import { type IExecuteFunctions, - type IDataObject, + type JsonObject, type ILoadOptionsFunctions, type INodeExecutionData, - type INodeParameters, type INodePropertyOptions, type INodeType, type INodeTypeDescription, NodeConnectionTypes, + NodeOperationError, + jsonParse, } from 'n8n-workflow'; +import type { Datapoint } from './Beeminder.node.functions'; import { createDatapoint, deleteDatapoint, getAllDatapoints, updateDatapoint, + createCharge, + uncleGoal, + createAllDatapoints, + getSingleDatapoint, + getGoal, + getAllGoals, + getArchivedGoals, + createGoal, + updateGoal, + refreshGoal, + shortCircuitGoal, + stepDownGoal, + cancelStepDownGoal, + getUser, } from './Beeminder.node.functions'; import { beeminderApiRequest } from './GenericFunctions'; +import { + assertIsString, + assertIsNodeParameters, + assertIsNumber, + assertIsArray, +} from '../../utils/types'; export class Beeminder implements INodeType { description: INodeTypeDescription = { @@ -39,9 +61,39 @@ export class Beeminder implements INodeType { { name: 'beeminderApi', required: true, + displayOptions: { + show: { + authentication: ['apiToken'], + }, + }, + }, + { + name: 'beeminderOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'API Token', + value: 'apiToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'apiToken', + }, { displayName: 'Resource', name: 'resource', @@ -49,10 +101,22 @@ export class Beeminder implements INodeType { noDataExpression: true, required: true, options: [ + { + name: 'Charge', + value: 'charge', + }, { name: 'Datapoint', value: 'datapoint', }, + { + name: 'Goal', + value: 'goal', + }, + { + name: 'User', + value: 'user', + }, ], default: 'datapoint', }, @@ -61,6 +125,32 @@ export class Beeminder implements INodeType { name: 'operation', type: 'options', noDataExpression: true, + displayOptions: { + show: { + resource: ['charge'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a charge', + action: 'Create a charge', + }, + ], + default: 'create', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['datapoint'], + }, + }, options: [ { name: 'Create', @@ -68,12 +158,24 @@ export class Beeminder implements INodeType { description: 'Create datapoint for goal', action: 'Create datapoint for goal', }, + { + name: 'Create All', + value: 'createAll', + description: 'Create multiple datapoints at once', + action: 'Create multiple datapoints at once', + }, { name: 'Delete', value: 'delete', description: 'Delete a datapoint', action: 'Delete a datapoint', }, + { + name: 'Get', + value: 'get', + description: 'Get a single datapoint', + action: 'Get a single datapoint', + }, { name: 'Get Many', value: 'getAll', @@ -90,6 +192,102 @@ export class Beeminder implements INodeType { default: 'create', required: true, }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['goal'], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new goal', + action: 'Create a new goal', + }, + { + name: 'Get', + value: 'get', + description: 'Get a specific goal', + action: 'Get a specific goal', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many goals', + action: 'Get many goals', + }, + { + name: 'Get Archived', + value: 'getArchived', + description: 'Get archived goals', + action: 'Get archived goals', + }, + { + name: 'Update', + value: 'update', + description: 'Update a goal', + action: 'Update a goal', + }, + { + name: 'Refresh', + value: 'refresh', + description: 'Refresh goal data', + action: 'Refresh goal data', + }, + { + name: 'Short Circuit', + value: 'shortCircuit', + description: 'Short circuit pledge', + action: 'Short circuit pledge', + }, + { + name: 'Step Down', + value: 'stepDown', + description: 'Step down pledge', + action: 'Step down pledge', + }, + { + name: 'Cancel Step Down', + value: 'cancelStepDown', + action: 'Cancel step down', + }, + { + name: 'Uncle', + value: 'uncle', + description: 'Derail a goal and charge the pledge amount', + action: 'Derail a goal and charge the pledge amount', + }, + ], + default: 'get', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get user information', + action: 'Get user information', + }, + ], + default: 'get', + required: true, + }, { displayName: 'Goal Name or ID', name: 'goalName', @@ -107,6 +305,161 @@ export class Beeminder implements INodeType { 'The name of the goal. Choose from the list, or specify an ID using an expression.', required: true, }, + { + displayName: 'Goal Name or ID', + name: 'goalName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGoals', + }, + displayOptions: { + show: { + resource: ['goal'], + operation: ['uncle'], + }, + }, + default: '', + description: + 'The name of the goal to derail. Choose from the list, or specify an ID using an expression.', + required: true, + }, + { + displayName: 'Goal Name or ID', + name: 'goalName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getGoals', + }, + displayOptions: { + show: { + resource: ['goal'], + operation: ['get', 'update', 'refresh', 'shortCircuit', 'stepDown', 'cancelStepDown'], + }, + }, + default: '', + description: + 'The name of the goal. Choose from the list, or specify an ID using an expression.', + required: true, + }, + { + displayName: 'Amount', + name: 'amount', + type: 'number', + displayOptions: { + show: { + resource: ['charge'], + operation: ['create'], + }, + }, + default: 0, + description: 'Charge amount in USD', + required: true, + }, + { + displayName: 'Datapoints', + name: 'datapoints', + type: 'json', + displayOptions: { + show: { + resource: ['datapoint'], + operation: ['createAll'], + }, + }, + default: '[]', + description: + 'Array of datapoint objects to create. Each object should contain value and optionally timestamp, comment, etc.', + placeholder: + '[{"value": 1, "comment": "First datapoint"}, {"value": 2, "comment": "Second datapoint"}]', + required: true, + }, + { + displayName: 'Goal Slug', + name: 'slug', + type: 'string', + displayOptions: { + show: { + resource: ['goal'], + operation: ['create'], + }, + }, + default: '', + description: 'Unique identifier for the goal', + required: true, + }, + { + displayName: 'Goal Title', + name: 'title', + type: 'string', + displayOptions: { + show: { + resource: ['goal'], + operation: ['create'], + }, + }, + default: '', + description: 'Human-readable title for the goal', + required: true, + }, + { + displayName: 'Goal Type', + name: 'goal_type', + type: 'options', + displayOptions: { + show: { + resource: ['goal'], + operation: ['create'], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Hustler', + value: 'hustler', + }, + { + name: 'Biker', + value: 'biker', + }, + { + name: 'Fatloser', + value: 'fatloser', + }, + { + name: 'Gainer', + value: 'gainer', + }, + { + name: 'Inboxer', + value: 'inboxer', + }, + { + name: 'Drinker', + value: 'drinker', + }, + { + name: 'Custom', + value: 'custom', + }, + ], + default: 'hustler', + description: + 'Type of goal. More info here..', + required: true, + }, + { + displayName: 'Goal Units', + name: 'gunits', + type: 'string', + displayOptions: { + show: { + resource: ['goal'], + operation: ['create'], + }, + }, + default: '', + description: 'Units for the goal (e.g., "hours", "pages", "pounds")', + required: true, + }, { displayName: 'Return All', name: 'returnAll', @@ -160,7 +513,8 @@ export class Beeminder implements INodeType { default: '', displayOptions: { show: { - operation: ['update', 'delete'], + resource: ['datapoint'], + operation: ['update', 'delete', 'get'], }, }, required: true, @@ -203,6 +557,361 @@ export class Beeminder implements INodeType { }, ], }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['charge'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Note', + name: 'note', + type: 'string', + default: '', + description: 'Charge explanation', + }, + { + displayName: 'Dry Run', + name: 'dryrun', + type: 'boolean', + default: false, + description: 'Whether to test charge creation without actually charging', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['goal'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Goal Date', + name: 'goaldate', + type: 'dateTime', + default: null, + description: 'Target date for the goal', + }, + { + displayName: 'Goal Value', + name: 'goalval', + type: 'number', + default: null, + description: 'Target value for the goal', + }, + { + displayName: 'Rate', + name: 'rate', + type: 'number', + default: null, + description: 'Rate of progress (units per day)', + }, + { + displayName: 'Initial Value', + name: 'initval', + type: 'number', + default: 0, + description: "Initial value for today's date", + }, + { + displayName: 'Secret', + name: 'secret', + type: 'boolean', + default: false, + description: 'Whether the goal is secret', + }, + { + displayName: 'Data Public', + name: 'datapublic', + type: 'boolean', + default: false, + description: 'Whether the data is public', + }, + { + displayName: 'Data Source', + name: 'datasource', + type: 'options', + options: [ + { + name: 'API', + value: 'api', + }, + { + name: 'IFTTT', + value: 'ifttt', + }, + { + name: 'Zapier', + value: 'zapier', + }, + { + name: 'Manual', + value: 'manual', + }, + ], + default: 'manual', + description: 'Data source for the goal', + }, + { + displayName: 'Dry Run', + name: 'dryrun', + type: 'boolean', + default: false, + description: 'Whether to test the endpoint without actually creating a goal', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'json', + default: '[]', + description: 'Array of alphanumeric tags for the goal. Replaces existing tags.', + placeholder: '["tag1", "tag2"]', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['goal'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Human-readable title for the goal', + }, + { + displayName: 'Y-Axis', + name: 'yaxis', + type: 'string', + default: '', + description: 'Y-axis label for the goal graph', + }, + { + displayName: 'Tmin', + name: 'tmin', + type: 'string', + default: '', + placeholder: 'yyyy-mm-dd', + description: 'Minimum date for the goal in format yyyy-mm-dd', + }, + { + displayName: 'Tmax', + name: 'tmax', + type: 'string', + default: '', + placeholder: 'yyyy-mm-dd', + description: 'Maximum date for the goal in format yyyy-mm-dd', + }, + { + displayName: 'Secret', + name: 'secret', + type: 'boolean', + default: false, + description: 'Whether the goal is secret', + }, + { + displayName: 'Data Public', + name: 'datapublic', + type: 'boolean', + default: false, + description: 'Whether the data is public', + }, + { + displayName: 'Road All', + name: 'roadall', + type: 'json', + default: '[]', + description: + 'Array of arrays defining the bright red line. Each sub-array contains [date, value, rate] with exactly one field null.', + placeholder: '[["2023-01-01", 0, null], [null, 100, 1]]', + }, + { + displayName: 'Data Source', + name: 'datasource', + type: 'options', + options: [ + { + name: 'API', + value: 'api', + }, + { + name: 'IFTTT', + value: 'ifttt', + }, + { + name: 'Zapier', + value: 'zapier', + }, + { + name: 'Manual', + value: '', + }, + ], + default: '', + description: 'Data source for the goal. Use empty string for manual entry.', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'json', + default: '[]', + description: 'Array of alphanumeric tags for the goal. Replaces existing tags.', + placeholder: '["tag1", "tag2"]', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['goal'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Include Datapoints', + name: 'datapoints', + type: 'boolean', + default: false, + description: 'Whether to include datapoints in the response', + }, + { + displayName: 'Emaciated', + name: 'emaciated', + type: 'boolean', + default: false, + description: + 'Whether to include the goal attributes called road, roadall, and fullroad will be stripped from the goal object', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['user'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Associations', + name: 'associations', + type: 'boolean', + default: false, + description: 'Whether to include associations in the response', + }, + { + displayName: 'Diff Since', + name: 'diff_since', + type: 'dateTime', + default: null, + description: + 'Only goals and datapoints that have been created or updated since the timestamp will be returned', + }, + { + displayName: 'Skinny', + name: 'skinny', + type: 'boolean', + default: false, + description: 'Whether to return minimal user data', + }, + { + displayName: 'Emaciated', + name: 'emaciated', + type: 'boolean', + default: false, + description: + 'Whether to include the goal attributes called road, roadall, and fullroad will be stripped from any goal objects returned with the user', + }, + { + displayName: 'Datapoints Count', + name: 'datapoints_count', + type: 'number', + default: null, + description: 'Number of datapoints to include', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['goal'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Emaciated', + name: 'emaciated', + type: 'boolean', + default: false, + description: + 'Whether to include the goal attributes called road, roadall, and fullroad will be stripped from the goal objects', + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['goal'], + operation: ['getArchived'], + }, + }, + options: [ + { + displayName: 'Emaciated', + name: 'emaciated', + type: 'boolean', + default: false, + description: + 'Whether to include the goal attributes called road, roadall, and fullroad will be stripped from the goal objects', + }, + ], + }, { displayName: 'Options', name: 'options', @@ -224,6 +933,37 @@ export class Beeminder implements INodeType { placeholder: '', description: 'Attribute to sort on', }, + { + displayName: 'Page', + name: 'page', + type: 'number', + displayOptions: { + show: { + '/returnAll': [false], + }, + }, + default: 1, + typeOptions: { + minValue: 1, + }, + description: 'Used to paginate results, 1-indexed, meaning page 1 is the first page', + }, + { + displayName: 'Per Page', + name: 'per', + type: 'number', + displayOptions: { + show: { + '/returnAll': [false], + }, + }, + default: 25, + typeOptions: { + minValue: 0, + }, + description: + 'Number of results per page. Default 25. Ignored without page parameter. Must be non-negative', + }, ], }, { @@ -272,9 +1012,7 @@ export class Beeminder implements INodeType { // Get all the available groups to display them to user so that they can // select them easily async getGoals(this: ILoadOptionsFunctions): Promise { - const credentials = await this.getCredentials('beeminderApi'); - - const endpoint = `/users/${credentials.user}/goals.json`; + const endpoint = '/users/me/goals.json'; const returnData: INodePropertyOptions[] = []; const goals = await beeminderApiRequest.call(this, 'GET', endpoint); @@ -297,82 +1035,35 @@ export class Beeminder implements INodeType { const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); - let results; for (let i = 0; i < length; i++) { try { + let results: JsonObject[]; + if (resource === 'datapoint') { - const goalName = this.getNodeParameter('goalName', i) as string; - if (operation === 'create') { - const value = this.getNodeParameter('value', i) as number; - const options = this.getNodeParameter('additionalFields', i) as INodeParameters; - const data: IDataObject = { - value, - goalName, - }; - Object.assign(data, options); - - if (data.timestamp) { - data.timestamp = moment.tz(data.timestamp, timezone).unix(); - } - results = await createDatapoint.call(this, data); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(results as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'getAll') { - const returnAll = this.getNodeParameter('returnAll', i); - const options = this.getNodeParameter('options', i) as INodeParameters; - const data: IDataObject = { - goalName, - }; - Object.assign(data, options); - - if (!returnAll) { - data.count = this.getNodeParameter('limit', 0); - } - - results = await getAllDatapoints.call(this, data); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(results as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'update') { - const datapointId = this.getNodeParameter('datapointId', i) as string; - const options = this.getNodeParameter('updateFields', i) as INodeParameters; - const data: IDataObject = { - goalName, - datapointId, - }; - Object.assign(data, options); - if (data.timestamp) { - data.timestamp = moment.tz(data.timestamp, timezone).unix(); - } - results = await updateDatapoint.call(this, data); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(results as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'delete') { - const datapointId = this.getNodeParameter('datapointId', i) as string; - const data: IDataObject = { - goalName, - datapointId, - }; - results = await deleteDatapoint.call(this, data); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(results as IDataObject[]), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } + const goalName = this.getNodeParameter('goalName', i); + assertIsString('goalName', goalName); + results = await executeDatapointOperations(this, operation, goalName, i, timezone); + } else if (resource === 'charge') { + results = await executeChargeOperations(this, operation, i); + } else if (resource === 'goal') { + results = await executeGoalOperations(this, operation, i, timezone); + } else if (resource === 'user') { + results = await executeUserOperations(this, operation, i, timezone); + } else { + throw new NodeOperationError(this.getNode(), `Unknown resource: ${resource}`); } + + const executionData = buildExecutionData(this, results, i); + returnData.push(...executionData); } catch (error) { if (this.continueOnFail()) { - returnData.push({ error: error.message, json: {}, itemIndex: i }); + const errorData = { + json: {}, + error: error instanceof NodeOperationError ? error : undefined, + itemIndex: i, + }; + returnData.push(errorData); continue; } throw error; @@ -382,3 +1073,483 @@ export class Beeminder implements INodeType { return [returnData]; } } + +function buildExecutionData( + context: IExecuteFunctions, + results: JsonObject[], + itemIndex: number, +): INodeExecutionData[] { + return context.helpers.constructExecutionMetaData(context.helpers.returnJsonArray(results), { + itemData: { item: itemIndex }, + }); +} + +async function executeDatapointCreate( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, + timezone: string, +): Promise { + const value = context.getNodeParameter('value', itemIndex); + assertIsNumber('value', value); + + const options = context.getNodeParameter('additionalFields', itemIndex); + if (options.timestamp) { + options.timestamp = moment.tz(options.timestamp, timezone).unix(); + } + + assertIsNodeParameters<{ + comment?: string; + timestamp?: number; + requestid?: string; + }>(options, { + comment: { type: 'string', optional: true }, + timestamp: { type: 'number', optional: true }, + requestid: { type: 'string', optional: true }, + }); + + const data = { + value, + goalName, + ...options, + }; + + return await createDatapoint.call(context, data); +} + +async function executeDatapointGetAll( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, +): Promise { + const returnAll = context.getNodeParameter('returnAll', itemIndex); + const options = context.getNodeParameter('options', itemIndex); + assertIsNodeParameters<{ + sort?: string; + page?: number; + per?: number; + }>(options, { + sort: { type: 'string', optional: true }, + page: { type: 'number', optional: true }, + per: { type: 'number', optional: true }, + }); + + const data = { + goalName, + count: !returnAll ? context.getNodeParameter('limit', 0) : undefined, + ...options, + }; + + return await getAllDatapoints.call(context, data); +} + +async function executeDatapointUpdate( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, + timezone: string, +): Promise { + const datapointId = context.getNodeParameter('datapointId', itemIndex); + assertIsString('datapointId', datapointId); + const options = context.getNodeParameter('updateFields', itemIndex); + if (options.timestamp) { + options.timestamp = moment.tz(options.timestamp, timezone).unix(); + } + + assertIsNodeParameters<{ + value?: number; + comment?: string; + timestamp?: number; + }>(options, { + value: { type: 'number', optional: true }, + comment: { type: 'string', optional: true }, + timestamp: { type: 'number', optional: true }, + }); + + const data = { + goalName, + datapointId, + ...options, + }; + + return await updateDatapoint.call(context, data); +} + +async function executeDatapointDelete( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, +): Promise { + const datapointId = context.getNodeParameter('datapointId', itemIndex); + assertIsString('datapointId', datapointId); + const data = { + goalName, + datapointId, + }; + return await deleteDatapoint.call(context, data); +} + +async function executeDatapointCreateAll( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, +): Promise { + const datapoints = context.getNodeParameter('datapoints', itemIndex); + const parsedDatapoints = typeof datapoints === 'string' ? jsonParse(datapoints) : datapoints; + assertIsArray( + 'datapoints', + parsedDatapoints, + (val): val is Datapoint => typeof val === 'object' && val !== null && 'value' in val, + ); + + const data = { + goalName, + datapoints: parsedDatapoints, + }; + return await createAllDatapoints.call(context, data); +} + +async function executeDatapointGet( + context: IExecuteFunctions, + goalName: string, + itemIndex: number, +): Promise { + const datapointId = context.getNodeParameter('datapointId', itemIndex); + assertIsString('datapointId', datapointId); + const data = { + goalName, + datapointId, + }; + return await getSingleDatapoint.call(context, data); +} + +async function executeDatapointOperations( + context: IExecuteFunctions, + operation: string, + goalName: string, + itemIndex: number, + timezone: string, +): Promise { + switch (operation) { + case 'create': + return await executeDatapointCreate(context, goalName, itemIndex, timezone); + case 'getAll': + return await executeDatapointGetAll(context, goalName, itemIndex); + case 'update': + return await executeDatapointUpdate(context, goalName, itemIndex, timezone); + case 'delete': + return await executeDatapointDelete(context, goalName, itemIndex); + case 'createAll': + return await executeDatapointCreateAll(context, goalName, itemIndex); + case 'get': + return await executeDatapointGet(context, goalName, itemIndex); + default: + throw new NodeOperationError(context.getNode(), `Unknown datapoint operation: ${operation}`); + } +} + +async function executeChargeOperations( + context: IExecuteFunctions, + operation: string, + itemIndex: number, +): Promise { + if (operation === 'create') { + const amount = context.getNodeParameter('amount', itemIndex); + assertIsNumber('amount', amount); + const options = context.getNodeParameter('additionalFields', itemIndex); + assertIsNodeParameters<{ + note?: string; + dryrun?: boolean; + }>(options, { + note: { type: 'string', optional: true }, + dryrun: { type: 'boolean', optional: true }, + }); + const data = { + amount, + ...options, + }; + + return await createCharge.call(context, data); + } + throw new NodeOperationError(context.getNode(), `Unknown charge operation: ${operation}`); +} + +async function executeGoalCreate( + context: IExecuteFunctions, + itemIndex: number, + timezone: string, +): Promise { + const slug = context.getNodeParameter('slug', itemIndex); + assertIsString('slug', slug); + const title = context.getNodeParameter('title', itemIndex); + assertIsString('title', title); + const goalType = context.getNodeParameter('goal_type', itemIndex); + assertIsString('goalType', goalType); + const gunits = context.getNodeParameter('gunits', itemIndex); + assertIsString('gunits', gunits); + const options = context.getNodeParameter('additionalFields', itemIndex); + if ('tags' in options && typeof options.tags === 'string') { + options.tags = jsonParse(options.tags); + } + if (options.goaldate && typeof options.goaldate === 'string') { + options.goaldate = moment.tz(options.goaldate, timezone).unix(); + } + + assertIsNodeParameters<{ + goaldate?: number; + goalval?: number; + rate?: number; + initval?: number; + secret?: boolean; + datapublic?: boolean; + datasource?: string; + dryrun?: boolean; + tags?: string[]; + }>(options, { + goaldate: { type: 'number', optional: true }, + goalval: { type: 'number', optional: true }, + rate: { type: 'number', optional: true }, + initval: { type: 'number', optional: true }, + secret: { type: 'boolean', optional: true }, + datapublic: { type: 'boolean', optional: true }, + datasource: { type: 'string', optional: true }, + dryrun: { type: 'boolean', optional: true }, + tags: { type: 'string[]', optional: true }, + }); + + const data = { + slug, + title, + goal_type: goalType, + gunits, + ...options, + }; + + return await createGoal.call(context, data); +} + +async function executeGoalGet( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + const options = context.getNodeParameter('additionalFields', itemIndex); + assertIsNodeParameters<{ + datapoints?: boolean; + emaciated?: boolean; + }>(options, { + datapoints: { type: 'boolean', optional: true }, + emaciated: { type: 'boolean', optional: true }, + }); + const data = { + goalName, + ...options, + }; + + return await getGoal.call(context, data); +} + +async function executeGoalGetAll( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const options = context.getNodeParameter('additionalFields', itemIndex); + assertIsNodeParameters<{ + emaciated?: boolean; + }>(options, { + emaciated: { type: 'boolean', optional: true }, + }); + const data = { ...options }; + + return await getAllGoals.call(context, data); +} + +async function executeGoalGetArchived( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const options = context.getNodeParameter('additionalFields', itemIndex); + assertIsNodeParameters<{ + emaciated?: boolean; + }>(options, { + emaciated: { type: 'boolean', optional: true }, + }); + const data = { ...options }; + + return await getArchivedGoals.call(context, data); +} + +async function executeGoalUpdate( + context: IExecuteFunctions, + itemIndex: number, + timezone: string, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + const options = context.getNodeParameter('updateFields', itemIndex); + if ('tags' in options && typeof options.tags === 'string') { + options.tags = jsonParse(options.tags); + } + if ('roadall' in options && typeof options.roadall === 'string') { + options.roadall = jsonParse(options.roadall); + } + console.log('roadall', typeof options.roadall, options.roadall); + assertIsNodeParameters<{ + title?: string; + yaxis?: string; + tmin?: string; + tmax?: string; + goaldate?: number; + secret?: boolean; + datapublic?: boolean; + roadall?: object; + datasource?: string; + tags?: string[]; + }>(options, { + title: { type: 'string', optional: true }, + yaxis: { type: 'string', optional: true }, + tmin: { type: 'string', optional: true }, + tmax: { type: 'string', optional: true }, + secret: { type: 'boolean', optional: true }, + datapublic: { type: 'boolean', optional: true }, + roadall: { type: 'object', optional: true }, + datasource: { type: 'string', optional: true }, + tags: { type: 'string[]', optional: true }, + }); + const data = { + goalName, + ...options, + }; + + if (data.goaldate) { + data.goaldate = moment.tz(data.goaldate, timezone).unix(); + } + return await updateGoal.call(context, data); +} + +async function executeGoalRefresh( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + const data = { + goalName, + }; + return await refreshGoal.call(context, data); +} + +async function executeGoalShortCircuit( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + + const data = { + goalName, + }; + return await shortCircuitGoal.call(context, data); +} + +async function executeGoalStepDown( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + + const data = { + goalName, + }; + return await stepDownGoal.call(context, data); +} + +async function executeGoalCancelStepDown( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + const data = { + goalName, + }; + return await cancelStepDownGoal.call(context, data); +} + +async function executeGoalUncle( + context: IExecuteFunctions, + itemIndex: number, +): Promise { + const goalName = context.getNodeParameter('goalName', itemIndex); + assertIsString('goalName', goalName); + const data = { + goalName, + }; + + return await uncleGoal.call(context, data); +} + +async function executeGoalOperations( + context: IExecuteFunctions, + operation: string, + itemIndex: number, + timezone: string, +): Promise { + switch (operation) { + case 'create': + return await executeGoalCreate(context, itemIndex, timezone); + case 'get': + return await executeGoalGet(context, itemIndex); + case 'getAll': + return await executeGoalGetAll(context, itemIndex); + case 'getArchived': + return await executeGoalGetArchived(context, itemIndex); + case 'update': + return await executeGoalUpdate(context, itemIndex, timezone); + case 'refresh': + return await executeGoalRefresh(context, itemIndex); + case 'shortCircuit': + return await executeGoalShortCircuit(context, itemIndex); + case 'stepDown': + return await executeGoalStepDown(context, itemIndex); + case 'cancelStepDown': + return await executeGoalCancelStepDown(context, itemIndex); + case 'uncle': + return await executeGoalUncle(context, itemIndex); + default: + throw new NodeOperationError(context.getNode(), `Unknown goal operation: ${operation}`); + } +} + +async function executeUserOperations( + context: IExecuteFunctions, + operation: string, + itemIndex: number, + timezone: string, +): Promise { + if (operation === 'get') { + const options = context.getNodeParameter('additionalFields', itemIndex); + if (options.diff_since) { + options.diff_since = moment.tz(options.diff_since, timezone).unix(); + } + assertIsNodeParameters<{ + associations?: boolean; + diff_since?: number; + skinny?: boolean; + emaciated?: boolean; + datapoints_count?: number; + }>(options, { + associations: { type: 'boolean', optional: true }, + diff_since: { type: 'number', optional: true }, + skinny: { type: 'boolean', optional: true }, + emaciated: { type: 'boolean', optional: true }, + datapoints_count: { type: 'number', optional: true }, + }); + const data = { ...options }; + + return await getUser.call(context, data); + } + throw new NodeOperationError(context.getNode(), `Unknown user operation: ${operation}`); +} diff --git a/packages/nodes-base/nodes/Beeminder/GenericFunctions.ts b/packages/nodes-base/nodes/Beeminder/GenericFunctions.ts index 7e2701b701..133ec8f70d 100644 --- a/packages/nodes-base/nodes/Beeminder/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Beeminder/GenericFunctions.ts @@ -7,9 +7,34 @@ import type { IHttpRequestMethods, IRequestOptions, } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; const BEEMINDER_URI = 'https://www.beeminder.com/api/v1'; +function isValidAuthenticationMethod(value: unknown): value is 'apiToken' | 'oAuth2' { + return typeof value === 'string' && ['apiToken', 'oAuth2'].includes(value); +} + +function convertToFormData(obj: any): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) { + // Skip null/undefined values + continue; + } else if (typeof value === 'boolean') { + result[key] = value.toString(); + } else if (typeof value === 'number') { + result[key] = value.toString(); + } else if (Array.isArray(value)) { + // Handle arrays - convert to JSON string for form data + result[key] = JSON.stringify(value); + } else { + result[key] = String(value); + } + } + return result; +} + export async function beeminderApiRequest( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, @@ -17,24 +42,45 @@ export async function beeminderApiRequest( body: any = {}, query: IDataObject = {}, + useFormData: boolean = false, ): Promise { + const authenticationMethod = this.getNodeParameter('authentication', 0, 'apiToken'); + + if (!isValidAuthenticationMethod(authenticationMethod)) { + throw new ApplicationError(`Invalid authentication method: ${authenticationMethod}`); + } + + let credentialType = 'beeminderApi'; + if (authenticationMethod === 'oAuth2') { + credentialType = 'beeminderOAuth2Api'; + } + const options: IRequestOptions = { method, - body, qs: query, uri: `${BEEMINDER_URI}${endpoint}`, json: true, }; + if (useFormData) { + options.formData = convertToFormData(body); + } else { + options.body = body; + } + if (!Object.keys(body as IDataObject).length) { - delete options.body; + if (useFormData) { + delete options.formData; + } else { + delete options.body; + } } if (!Object.keys(query).length) { delete options.qs; } - return await this.helpers.requestWithAuthentication.call(this, 'beeminderApi', options); + return await this.helpers.requestWithAuthentication.call(this, credentialType, options); } export async function beeminderApiRequestAllItems( diff --git a/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.functions.test.ts b/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.functions.test.ts new file mode 100644 index 0000000000..64e59a7331 --- /dev/null +++ b/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.functions.test.ts @@ -0,0 +1,669 @@ +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; + +import { + createDatapoint, + getAllDatapoints, + updateDatapoint, + deleteDatapoint, + createCharge, + uncleGoal, + createAllDatapoints, + getSingleDatapoint, + getGoal, + getAllGoals, + getArchivedGoals, + createGoal, + updateGoal, + refreshGoal, + shortCircuitGoal, + stepDownGoal, + cancelStepDownGoal, + getUser, + type Datapoint, +} from '../Beeminder.node.functions'; +import * as GenericFunctions from '../GenericFunctions'; + +// Mock the GenericFunctions +jest.mock('../GenericFunctions'); +const mockedGenericFunctions = jest.mocked(GenericFunctions); + +describe('Beeminder Node Functions', () => { + let mockContext: IExecuteFunctions; + + beforeEach(() => { + mockContext = mock(); + jest.clearAllMocks(); + }); + + describe('Datapoint Operations', () => { + describe('createDatapoint', () => { + it('should create a datapoint with required parameters', async () => { + const mockResponse = { id: '123', value: 10, timestamp: 1234567890 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + value: 10, + }; + + const result = await createDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/datapoints.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should create a datapoint with all optional parameters', async () => { + const mockResponse = { id: '123', value: 10, timestamp: 1234567890 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + value: 10, + timestamp: 1234567890, + comment: 'Test comment', + requestid: 'req123', + }; + + const result = await createDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/datapoints.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('getAllDatapoints', () => { + it('should get all datapoints when count is not specified', async () => { + const mockResponse = [{ id: '1' }, { id: '2' }]; + mockedGenericFunctions.beeminderApiRequestAllItems.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await getAllDatapoints.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal/datapoints.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + + it('should get limited datapoints when count is specified', async () => { + const mockResponse = [{ id: '1' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal', count: 1 }; + + const result = await getAllDatapoints.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal/datapoints.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + + it('should handle optional parameters', async () => { + const mockResponse = [{ id: '1' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + count: 5, + sort: 'id', + page: 2, + per: 10, + }; + + const result = await getAllDatapoints.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal/datapoints.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('updateDatapoint', () => { + it('should update a datapoint with required parameters', async () => { + const mockResponse = { id: '123', value: 15 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + datapointId: '123', + }; + + const result = await updateDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'PUT', + '/users/me/goals/testgoal/datapoints/123.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should update a datapoint with all optional parameters', async () => { + const mockResponse = { id: '123', value: 15 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + datapointId: '123', + value: 15, + comment: 'Updated comment', + timestamp: 1234567890, + }; + + const result = await updateDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'PUT', + '/users/me/goals/testgoal/datapoints/123.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('deleteDatapoint', () => { + it('should delete a datapoint', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + datapointId: '123', + }; + + const result = await deleteDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/users/me/goals/testgoal/datapoints/123.json', + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('createAllDatapoints', () => { + it('should create multiple datapoints', async () => { + const mockResponse = { created: 2 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const datapoints: Datapoint[] = [ + { timestamp: 1234567890, value: 10, comment: 'First' }, + { timestamp: 1234567891, value: 20, comment: 'Second' }, + ]; + + const data = { + goalName: 'testgoal', + datapoints, + }; + + const result = await createAllDatapoints.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/datapoints/create_all.json', + { datapoints }, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('getSingleDatapoint', () => { + it('should get a single datapoint', async () => { + const mockResponse = { id: '123', value: 10 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + datapointId: '123', + }; + + const result = await getSingleDatapoint.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal/datapoints/123.json', + ); + expect(result).toBe(mockResponse); + }); + }); + }); + + describe('Goal Operations', () => { + describe('getGoal', () => { + it('should get a goal with basic parameters', async () => { + const mockResponse = { slug: 'testgoal', title: 'Test Goal' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await getGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + + it('should get a goal with optional parameters', async () => { + const mockResponse = { slug: 'testgoal', title: 'Test Goal' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + datapoints: true, + emaciated: false, + }; + + const result = await getGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('getAllGoals', () => { + it('should get all goals without parameters', async () => { + const mockResponse = [{ slug: 'goal1' }, { slug: 'goal2' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const result = await getAllGoals.call(mockContext); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals.json', + {}, + {}, + ); + expect(result).toBe(mockResponse); + }); + + it('should get all goals with emaciated parameter', async () => { + const mockResponse = [{ slug: 'goal1' }, { slug: 'goal2' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { emaciated: true }; + + const result = await getAllGoals.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('getArchivedGoals', () => { + it('should get archived goals without parameters', async () => { + const mockResponse = [{ slug: 'archived1' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const result = await getArchivedGoals.call(mockContext); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/archived.json', + {}, + {}, + ); + expect(result).toBe(mockResponse); + }); + + it('should get archived goals with emaciated parameter', async () => { + const mockResponse = [{ slug: 'archived1' }]; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { emaciated: true }; + + const result = await getArchivedGoals.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/archived.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('createGoal', () => { + it('should create a goal with required parameters', async () => { + const mockResponse = { slug: 'newgoal', id: 'goal123' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + slug: 'newgoal', + title: 'New Goal', + goal_type: 'hustler', + gunits: 'hours', + }; + + const result = await createGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should create a goal with all optional parameters', async () => { + const mockResponse = { slug: 'newgoal', id: 'goal123' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + slug: 'newgoal', + title: 'New Goal', + goal_type: 'hustler', + gunits: 'hours', + goaldate: 1234567890, + goalval: 100, + rate: 1, + initval: 0, + secret: false, + datapublic: true, + datasource: 'manual', + dryrun: false, + tags: ['productivity', 'work'], + }; + + const result = await createGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('updateGoal', () => { + it('should update a goal with goalName', async () => { + const mockResponse = { slug: 'testgoal', title: 'Updated Title' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + title: 'Updated Title', + }; + + const result = await updateGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'PUT', + '/users/me/goals/testgoal.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should update a goal with all optional parameters', async () => { + const mockResponse = { slug: 'testgoal' }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + goalName: 'testgoal', + title: 'Updated Title', + yaxis: 'Hours worked', + tmin: '08:00', + tmax: '18:00', + secret: true, + datapublic: false, + roadall: { rate: 2 }, + datasource: 'api', + tags: ['work', 'productivity'], + }; + + const result = await updateGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'PUT', + '/users/me/goals/testgoal.json', + data, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('refreshGoal', () => { + it('should refresh a goal', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await refreshGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me/goals/testgoal/refresh_graph.json', + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('shortCircuitGoal', () => { + it('should short circuit a goal', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await shortCircuitGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/shortcircuit.json', + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('stepDownGoal', () => { + it('should step down a goal', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await stepDownGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/stepdown.json', + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('cancelStepDownGoal', () => { + it('should cancel step down for a goal', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await cancelStepDownGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/cancel_stepdown.json', + ); + expect(result).toBe(mockResponse); + }); + }); + }); + + describe('Charge Operations', () => { + describe('createCharge', () => { + it('should create a charge with required amount', async () => { + const mockResponse = { id: 'charge123', amount: 5 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { amount: 5 }; + + const result = await createCharge.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/charges.json', + { + user_id: 'me', + amount: 5, + }, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should create a charge with all optional parameters', async () => { + const mockResponse = { id: 'charge123', amount: 10 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + amount: 10, + note: 'Penalty charge', + dryrun: true, + }; + + const result = await createCharge.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/charges.json', + { + user_id: 'me', + amount: 10, + note: 'Penalty charge', + dryrun: true, + }, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + + it('should not include undefined optional parameters', async () => { + const mockResponse = { id: 'charge123', amount: 5 }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + amount: 5, + note: undefined, + dryrun: undefined, + }; + + const result = await createCharge.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/charges.json', + { + user_id: 'me', + amount: 5, + }, + {}, + true, + ); + expect(result).toBe(mockResponse); + }); + }); + + describe('uncleGoal', () => { + it('should uncle a goal', async () => { + const mockResponse = { success: true }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { goalName: 'testgoal' }; + + const result = await uncleGoal.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'POST', + '/users/me/goals/testgoal/uncleme.json', + ); + expect(result).toBe(mockResponse); + }); + }); + }); + + describe('User Operations', () => { + describe('getUser', () => { + it('should get user information', async () => { + const mockResponse = { username: 'testuser', goals: [] }; + mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse); + + const data = { + associations: true, + diff_since: 1234567890, + skinny: false, + emaciated: false, + datapoints_count: 10, + }; + + const result = await getUser.call(mockContext, data); + + expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith( + 'GET', + '/users/me.json', + {}, + data, + ); + expect(result).toBe(mockResponse); + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts b/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts new file mode 100644 index 0000000000..eaa4329fd2 --- /dev/null +++ b/packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts @@ -0,0 +1,124 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import type { WorkflowTestData } from 'n8n-workflow'; +import nock from 'nock'; + +const userInfo = { + username: 'test', + timezone: 'Europe/Zurich', + goals: ['test3333', 'test333'], + created_at: 1520089425, + updated_at: 1753992556, + urgency_load: 43, + deadbeat: false, + has_authorized_fitbit: false, + default_leadtime: 0, + default_alertstart: 34200, + default_deadline: -43260, + subscription: 'infinibee', + subs_downto: 'infinibee', + subs_freq: 24, + subs_lifetime: null, + remaining_subs_credit: 31, + id: '35555555555', +}; + +const chargeInfo = { + amount: 10, + id: { + $oid: '688bcd7cf0168a11bee246ffff', + }, + note: 'Created by test-test-oauth2', + status: null, + username: 'test', +}; + +const goalInfo = { + slug: 'test3333', + title: 'test title', + description: null, + goalval: 1000, + rate: 1, + rah: null, + callback_url: null, + tags: [], + recent_data: [ + { + id: '688bc5fef0168a11bee24691', + timestamp: 1754042339, + daystamp: '20250801', + value: 0, + comment: 'initial datapoint of 0 on the 1st', + updated_at: 1753990654, + requestid: null, + origin: 'nihilo', + creator: '', + is_dummy: false, + is_initial: true, + urtext: null, + fulltext: '2025-Aug-01 entered at 21:37 on 2025-Jul-31 ex nihilo', + canonical: '01 0 "initial datapoint of 0 on the 1st"', + created_at: '2025-07-31T19:37:34.000Z', + }, + ], + dueby: null, +}; + +const newDatapoint = { + id: '688bc54ef0168a11bee2468b', + timestamp: 1753990478, + daystamp: '20250801', + value: 1, + comment: '', + updated_at: 1753990478, + requestid: null, + origin: 'test-test-oauth2', + creator: 'testuser', + is_dummy: false, + is_initial: false, + urtext: null, + fulltext: '2025-Aug-01 entered at 21:34 on 2025-Jul-31 by test-ser via test-test-oauth2', + canonical: '01 1', + created_at: '2025-07-31T19:34:38.000Z', + status: 'created', +}; + +describe('Execute Beeminder Node', () => { + const testHarness = new NodeTestHarness(); + + beforeEach(() => { + const beeminderNock = nock('https://www.beeminder.com'); + beeminderNock.get('/api/v1/users/me.json').reply(200, userInfo); + beeminderNock.post('/api/v1/charges.json').reply(200, chargeInfo); + beeminderNock.get('/api/v1/users/me/goals.json').reply(200, [goalInfo]); + beeminderNock.post('/api/v1/users/me/goals.json').reply(200, goalInfo); + beeminderNock + .post(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints.json`) + .reply(200, newDatapoint); + beeminderNock + .put(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints/${newDatapoint.id}.json`) + .reply(200, newDatapoint); + beeminderNock + .delete(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints/${newDatapoint.id}.json`) + .reply(200, newDatapoint); + }); + + const testData: WorkflowTestData = { + description: 'Execute operations', + input: { + workflowData: testHarness.readWorkflowJSON('workflow.json'), + }, + output: { + nodeData: { + 'Get user information': [[{ json: userInfo }]], + 'Create a charge': [[{ json: chargeInfo }]], + 'Get many goals': [[{ json: goalInfo }]], + 'Create a new goal': [[{ json: goalInfo }]], + 'Create datapoint for goal': [[{ json: newDatapoint }]], + 'Update a datapoint': [[{ json: newDatapoint }]], + 'Delete a datapoint': [[{ json: newDatapoint }]], + }, + }, + }; + + testHarness.setupTest(testData, { credentials: { beeminderApi: {} } }); +}); diff --git a/packages/nodes-base/nodes/Beeminder/test/workflow.json b/packages/nodes-base/nodes/Beeminder/test/workflow.json new file mode 100644 index 0000000000..d31d647af3 --- /dev/null +++ b/packages/nodes-base/nodes/Beeminder/test/workflow.json @@ -0,0 +1,238 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-848, -400], + "id": "852d5569-78ec-4d61-8b9c-8bdbe963fe6e", + "name": "When clicking ‘Execute workflow’" + }, + { + "parameters": { + "authentication": "oAuth2", + "resource": "goal", + "operation": "create", + "slug": "test333", + "title": "test title", + "gunits": "unit", + "additionalFields": { + "goalval": 1000, + "rate": 1 + } + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [48, -400], + "id": "0fbc833e-f4fb-430e-a130-9fd6655a35f1", + "name": "Create a new goal", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "goalName": "={{ $json.slug }}", + "additionalFields": {} + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [272, -400], + "id": "b143d8f4-399c-47e9-9497-002497d0a2b3", + "name": "Create datapoint for goal", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "operation": "delete", + "goalName": "={{ $('Create a new goal').item.json.slug }}", + "datapointId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [720, -400], + "id": "69ef60fd-c66b-4cba-aaf6-96e4fd453ac2", + "name": "Delete a datapoint", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "resource": "goal", + "operation": "getAll", + "additionalFields": {} + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [-176, -400], + "id": "1bdce766-3434-4e43-81bb-80a313ef4f2d", + "name": "Get many goals", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "resource": "user", + "additionalFields": {} + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [-624, -400], + "id": "25bb239d-d990-4b82-9416-a282cc00f467", + "name": "Get user information", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "resource": "charge", + "amount": 10, + "additionalFields": { + "dryrun": true + } + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [-400, -400], + "id": "2fa9ea2e-e4e7-4291-bb6d-75a6bc15bc4e", + "name": "Create a charge", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + }, + { + "parameters": { + "authentication": "oAuth2", + "operation": "update", + "goalName": "={{ $('Create a new goal').item.json.slug }}", + "datapointId": "={{ $json.id }}", + "updateFields": {} + }, + "type": "n8n-nodes-base.beeminder", + "typeVersion": 1, + "position": [496, -400], + "id": "55d4490f-97a2-405b-8745-421291d0c82e", + "name": "Update a datapoint", + "credentials": { + "beeminderOAuth2Api": { + "id": "tXjFNZhKeJFjFOl7", + "name": "Beeminder account 3" + } + } + } + ], + "connections": { + "When clicking ‘Execute workflow’": { + "main": [ + [ + { + "node": "Get user information", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a new goal": { + "main": [ + [ + { + "node": "Create datapoint for goal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create datapoint for goal": { + "main": [ + [ + { + "node": "Update a datapoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get many goals": { + "main": [ + [ + { + "node": "Create a new goal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get user information": { + "main": [ + [ + { + "node": "Create a charge", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a charge": { + "main": [ + [ + { + "node": "Get many goals", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a datapoint": { + "main": [ + [ + { + "node": "Delete a datapoint", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "When clicking ‘Execute workflow’": [{}] + }, + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "f0e9801eba0feea6a9ddf9beeabe34b0843eae42a1dbc62eaadd68e8f576be64" + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 358c24d21c..629a625b9b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -48,6 +48,7 @@ "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BaserowApi.credentials.js", "dist/credentials/BeeminderApi.credentials.js", + "dist/credentials/BeeminderOAuth2Api.credentials.js", "dist/credentials/BitbucketApi.credentials.js", "dist/credentials/BitlyApi.credentials.js", "dist/credentials/BitlyOAuth2Api.credentials.js", diff --git a/packages/nodes-base/utils/types.ts b/packages/nodes-base/utils/types.ts new file mode 100644 index 0000000000..c10fdc3085 --- /dev/null +++ b/packages/nodes-base/utils/types.ts @@ -0,0 +1,94 @@ +import { assert } from 'n8n-workflow'; + +function assertIsType( + parameterName: string, + value: unknown, + type: 'string' | 'number' | 'boolean', +): asserts value is T { + assert(typeof value === type, `Parameter "${parameterName}" is not ${type}`); +} + +export function assertIsNumber(parameterName: string, value: unknown): asserts value is number { + assertIsType(parameterName, value, 'number'); +} + +export function assertIsString(parameterName: string, value: unknown): asserts value is string { + assertIsType(parameterName, value, 'string'); +} + +export function assertIsArray( + parameterName: string, + value: unknown, + validator: (val: unknown) => val is T, +): asserts value is T[] { + assert(Array.isArray(value), `Parameter "${parameterName}" is not an array`); + assert( + value.every(validator), + `Parameter "${parameterName}" has elements that don't match expected types`, + ); +} + +export function assertIsNodeParameters( + value: unknown, + parameters: Record< + string, + { + type: + | 'string' + | 'boolean' + | 'number' + | 'resource-locator' + | 'string[]' + | 'number[]' + | 'boolean[]' + | 'object'; + optional?: boolean; + } + >, +): asserts value is T { + assert(typeof value === 'object' && value !== null, 'Value is not a valid object'); + + const obj = value as Record; + + Object.keys(parameters).forEach((key) => { + const param = parameters[key]; + const paramValue = obj[key]; + + if (!param.optional && paramValue === undefined) { + assert(false, `Required parameter "${key}" is missing`); + } + + if (paramValue !== undefined) { + if (param.type === 'resource-locator') { + assert( + typeof paramValue === 'object' && + paramValue !== null && + '__rl' in paramValue && + 'mode' in paramValue && + 'value' in paramValue, + `Parameter "${key}" is not a valid resource locator object`, + ); + } else if (param.type === 'object') { + assert( + typeof paramValue === 'object' && paramValue !== null, + `Parameter "${key}" is not a valid object`, + ); + } else if (param.type.endsWith('[]')) { + const baseType = param.type.slice(0, -2); + const elementType = + baseType === 'string' || baseType === 'number' || baseType === 'boolean' + ? baseType + : 'string'; + assert(Array.isArray(paramValue), `Parameter "${key}" is not an array`); + paramValue.forEach((item, index) => { + assert( + typeof item === elementType, + `Parameter "${key}[${index}]" is not a valid ${elementType}`, + ); + }); + } else { + assert(typeof paramValue === param.type, `Parameter "${key}" is not a valid ${param.type}`); + } + } + }); +}