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}`);
+ }
+ }
+ });
+}