From ef0248be992d1d95f1d622e4be9839c486110e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Thu, 28 Aug 2025 10:46:08 +0200 Subject: [PATCH 01/10] feat: add agentic tools support --- build-and-run.sh | 3 + nodes/EasyRedmine/EasyRedmine.node.ts | 28 +++- nodes/EasyRedmine/Model.ts | 1 + nodes/EasyRedmine/fields/IssueFields.ts | 134 +++++++++++++++++- .../EasyRedmine/operations/CreateOperation.ts | 10 +- .../EasyRedmine/operations/SearchOperation.ts | 95 +++++++++++++ 6 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 nodes/EasyRedmine/operations/SearchOperation.ts diff --git a/build-and-run.sh b/build-and-run.sh index b3de3b9..d46c0ee 100755 --- a/build-and-run.sh +++ b/build-and-run.sh @@ -26,6 +26,9 @@ fi cd $PWD +LANGCHAIN_TRACING_V2=true \ +LANGCHAIN_API_KEY=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 \ +LANGCHAIN_PROJECT=n8n \ N8N_LOG_LEVEL=debug \ CODE_ENABLE_STDOUT=true \ n8n diff --git a/nodes/EasyRedmine/EasyRedmine.node.ts b/nodes/EasyRedmine/EasyRedmine.node.ts index f3a9164..3b3f633 100644 --- a/nodes/EasyRedmine/EasyRedmine.node.ts +++ b/nodes/EasyRedmine/EasyRedmine.node.ts @@ -2,6 +2,7 @@ import { IDataObject, IExecuteFunctions, + ILoadOptionsFunctions, INodeExecutionData, INodeType, INodeTypeDescription, @@ -22,6 +23,9 @@ import { createOperation } from './operations/CreateOperation'; import { TimeEntryFields } from './fields/TimeEntryFields'; import { AttendanceFields } from './fields/AttendanceFields'; import { loadOptions } from './LoadOptions'; +import { EasyRedmineClient } from './client'; +import { INodeListSearchResult } from 'n8n-workflow/dist/esm/interfaces'; +import {processSearchOperation} from "./operations/SearchOperation"; /** * Node that enables communication with EasyRedmine. @@ -43,6 +47,7 @@ export class EasyRedmine implements INodeType { }, inputs: ['main'], outputs: ['main'], + usableAsTool: true, credentials: [ { name: 'easyRedmineApi', @@ -109,7 +114,6 @@ export class EasyRedmine implements INodeType { displayOptions: { show: { resource: [ - EasyNodeResourceType.issues, EasyNodeResourceType.leads, EasyNodeResourceType.opportunities, EasyNodeResourceType.accounts, @@ -218,6 +222,25 @@ export class EasyRedmine implements INodeType { methods = { loadOptions, + listSearch: { + getProjects: async function ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ): Promise { + const client = new EasyRedmineClient(this, this.helpers); + const projects = (await client.listProjects()).sort((p0, p1) => + p0.name.localeCompare(p1.name), + ); + return { + results: projects.map((project) => ({ + name: project.name, + value: project.id, + })), + paginationToken: undefined, + }; + }, + }, }; async execute(this: IExecuteFunctions): Promise { @@ -236,6 +259,9 @@ export class EasyRedmine implements INodeType { case EasyNodeOperationType.getMany: responseData = await processGetManyOperation.call(this, resource, itemIndex); break; + case EasyNodeOperationType.search: + responseData = await processSearchOperation.call(this, resource, itemIndex); + break; case EasyNodeOperationType.getOne: responseData = await processGetOneOperation.call(this, resource, itemIndex); break; diff --git a/nodes/EasyRedmine/Model.ts b/nodes/EasyRedmine/Model.ts index 7e91ed1..03f1e7c 100644 --- a/nodes/EasyRedmine/Model.ts +++ b/nodes/EasyRedmine/Model.ts @@ -1,5 +1,6 @@ export enum EasyNodeOperationType { getMany = 'get-many', + search = 'search', getOne = 'get-one', addComment = 'add-comment', create = 'create', diff --git a/nodes/EasyRedmine/fields/IssueFields.ts b/nodes/EasyRedmine/fields/IssueFields.ts index 76b0ddc..d7ae66f 100644 --- a/nodes/EasyRedmine/fields/IssueFields.ts +++ b/nodes/EasyRedmine/fields/IssueFields.ts @@ -5,13 +5,41 @@ import { CustomFieldsOption } from './CustomFields'; const ProjectIdField: INodeProperties = { displayName: 'Project Name or ID', name: 'projectId', - type: 'options', + type: 'resourceLocator', description: 'ID of the project. Choose from the list, or specify an ID using an expression.', - default: '', - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'Project', + name: 'list', + type: 'list', + placeholder: 'Select a Project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: false, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '10000', + validation: [ + { + type: 'regex', + properties: { + regex: '([0-9]{2,})[ \t]*', + errorMessage: 'Not a valid Project ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '^([0-9]{2,})', + }, + }, + ] }; const CommonIssueFields: INodeProperties[] = [ @@ -109,6 +137,58 @@ const CommonIssueFields: INodeProperties[] = [ ]; export const IssueFields: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + EasyNodeResourceType.issues, + ], + }, + }, + default: 'get-many', + options: [ + { + name: 'Get One', + description: 'Get a single entity', + value: EasyNodeOperationType.getOne, + action: 'Get one', + }, + { + name: 'Get Many', + description: 'Get multiple entities', + value: EasyNodeOperationType.getMany, + action: 'Get many', + }, + { + name: 'Search', + description: 'Search for multiple issues', + value: EasyNodeOperationType.search, + action: 'Search', + }, + { + name: 'Add Comment', + description: 'Add a comment to entity', + value: EasyNodeOperationType.addComment, + action: 'Add comment', + }, + { + name: 'Create', + description: 'Create entity', + value: EasyNodeOperationType.create, + action: 'Create', + }, + { + name: 'Update', + description: 'Update entity', + value: EasyNodeOperationType.update, + action: 'Update', + }, + ], + }, { displayName: 'Issue ID', name: 'id', @@ -207,4 +287,48 @@ export const IssueFields: INodeProperties[] = [ CustomFieldsOption, ], }, + + { + displayName: 'Search Fields', + name: 'issueSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.issues], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Query the name of the issue', + }, + { + displayName: 'Assigned To ID', + name: 'assignedToId', + type: 'string', + default: '', + description: 'ID of the user the issue is assigned to', + }, + { + displayName: 'Due Date From', + name: 'dueDateFrom', + type: 'string', + default: '', + placeholder: '2025-01-01', + }, + { + displayName: 'Due Date To', + name: 'dueDateTo', + type: 'string', + default: '', + placeholder: '2025-01-31', + } + ], + }, ]; diff --git a/nodes/EasyRedmine/operations/CreateOperation.ts b/nodes/EasyRedmine/operations/CreateOperation.ts index 4e3cace..e1b7512 100644 --- a/nodes/EasyRedmine/operations/CreateOperation.ts +++ b/nodes/EasyRedmine/operations/CreateOperation.ts @@ -97,7 +97,13 @@ function createBodyForIssue(this: IExecuteFunctions, itemIndex: number): { [key: this.logger.debug(`Create issue with subject: ${JSON.stringify(options)}`); const subject = this.getNodeParameter('subject', itemIndex) as string; - const projectId = this.getNodeParameter('projectId', itemIndex) as string; + let projectId = this.getNodeParameter('projectId', itemIndex) as any; + if (projectId['mode'] === 'id') { + projectId = projectId['value']; + } + else { + throw new Error('Only project by ID is supported'); + } const customFields = convertCustomFields(options); return { @@ -298,7 +304,7 @@ export async function createOperation( json: true, }; - this.logger.debug(`Create ${resource} with ${JSON.stringify(options)}`); + this.logger.info(`Create ${resource} with ${JSON.stringify(options)}`); return await this.helpers.httpRequestWithAuthentication.call(this, 'easyRedmineApi', options); } diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts new file mode 100644 index 0000000..5bd0e0c --- /dev/null +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -0,0 +1,95 @@ +import { IDataObject, IExecuteFunctions, IHttpRequestOptions } from 'n8n-workflow'; +import { EasyNodeResourceType } from '../Model'; +import { sanitizeDomain } from '../utils'; + +function enhanceIssueRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('issueSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + qs.set_filter = '1'; + qs.type = 'EasyIssueQuery'; + qs.sort = 'priority:desc, due_date'; + + if (options.assignedToId) { + qs['f[assigned_to_id]'] = options.assignedToId; + // = 'me' - trick + // query["f[easy_helpdesk_ticket_owner_id]"] = f"={ticket_owner_id}" + } + + if (options.dueDateFrom || options.dueDateTo) { + const dueDateFrom = options.dueDateFrom ?? '2000-01-01'; + const dueDateTo = options.dueDateTo ?? '2040-01-01'; + qs['f[due_date]'] = `${dueDateFrom}|${dueDateTo}`; + } + + if (options.query) { + qs.easy_query_q = options.query; + } +} + +export async function processSearchOperation( + this: IExecuteFunctions, + resource: EasyNodeResourceType, + itemIndex: number, +) { + const credentials = await this.getCredentials('easyRedmineApi'); + const qs: IDataObject = {}; + + const domain = sanitizeDomain(credentials.domain as string); + + const returnAll = this.getNodeParameter('returnAll', itemIndex, false); + + const resultItems = []; + + let offset = 0; + let limit = 100; + if (!returnAll) { + offset = this.getNodeParameter('offset', itemIndex, 0) as number; + limit = this.getNodeParameter('limit', itemIndex, 100) as number; + } + + let fetchedItemsCount = 0; + do { + const options: IHttpRequestOptions = { + method: 'GET', + url: `${domain}/${resource}.json`, + qs: { + ...qs, + offset, + limit, + }, + json: true, + }; + + switch (resource) { + case EasyNodeResourceType.issues: + enhanceIssueRequestOptions.call(this, itemIndex, options); + break; + default: + throw new Error('Not implemented!'); + } + + this.logger.info(`Requesting ${resource} with ${JSON.stringify(options)}`); + + const subResult = await this.helpers.httpRequestWithAuthentication.call( + this, + 'easyRedmineApi', + options, + ); + + resultItems.push(...subResult[resource]); + fetchedItemsCount = subResult[resource].length; + offset += fetchedItemsCount; + } while (fetchedItemsCount >= limit && returnAll); + + this.logger.debug(`Fetched all ${resource} ${resultItems.length} items`); + + const result: any = {}; + result[resource] = resultItems; + return result; +} From bbe87f41f918532629294840442566f088f64a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Thu, 28 Aug 2025 11:04:52 +0200 Subject: [PATCH 02/10] Remove --- build-and-run.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/build-and-run.sh b/build-and-run.sh index d46c0ee..b3de3b9 100755 --- a/build-and-run.sh +++ b/build-and-run.sh @@ -26,9 +26,6 @@ fi cd $PWD -LANGCHAIN_TRACING_V2=true \ -LANGCHAIN_API_KEY=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 \ -LANGCHAIN_PROJECT=n8n \ N8N_LOG_LEVEL=debug \ CODE_ENABLE_STDOUT=true \ n8n From 2b9caed845bc289885ad1155cb331a1dd6e4f518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Mon, 1 Sep 2025 11:40:56 +0200 Subject: [PATCH 03/10] Cleaning code around projectId --- nodes/EasyRedmine/LoadOptions.ts | 13 ----- nodes/EasyRedmine/fields/IssueFields.ts | 47 ++----------------- nodes/EasyRedmine/fields/OpportunityFields.ts | 31 ++---------- nodes/EasyRedmine/fields/ProjectIdField.ts | 41 ++++++++++++++++ nodes/EasyRedmine/fields/TimeEntryFields.ts | 16 ++----- .../EasyRedmine/operations/CreateOperation.ts | 10 +--- .../EasyRedmine/operations/GetOneOperation.ts | 2 +- nodes/EasyRedmine/utils/GetProjectId.ts | 18 +++++++ nodes/EasyRedmine/utils/index.ts | 1 + 9 files changed, 74 insertions(+), 105 deletions(-) create mode 100644 nodes/EasyRedmine/fields/ProjectIdField.ts create mode 100644 nodes/EasyRedmine/utils/GetProjectId.ts diff --git a/nodes/EasyRedmine/LoadOptions.ts b/nodes/EasyRedmine/LoadOptions.ts index a68302d..5795507 100644 --- a/nodes/EasyRedmine/LoadOptions.ts +++ b/nodes/EasyRedmine/LoadOptions.ts @@ -109,19 +109,6 @@ export const loadOptions: { .sort((a, b) => a.name.localeCompare(b.name)); }, - getAccessibleProjects: async function ( - this: ILoadOptionsFunctions, - ): Promise { - const client = new EasyRedmineClient(this, this.helpers); - const projects = await client.listProjects(); - return projects - .map((project) => ({ - name: project.name, - value: project.id, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, - getAvailablePriorities: async function ( this: ILoadOptionsFunctions, ): Promise { diff --git a/nodes/EasyRedmine/fields/IssueFields.ts b/nodes/EasyRedmine/fields/IssueFields.ts index d7ae66f..7580344 100644 --- a/nodes/EasyRedmine/fields/IssueFields.ts +++ b/nodes/EasyRedmine/fields/IssueFields.ts @@ -1,46 +1,7 @@ import { EasyNodeOperationType, EasyNodeResourceType } from '../Model'; import { INodeProperties } from 'n8n-workflow'; import { CustomFieldsOption } from './CustomFields'; - -const ProjectIdField: INodeProperties = { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'resourceLocator', - description: - 'ID of the project. Choose from the list, or specify an ID using an expression.', - default: { mode: 'list', value: '' }, - modes: [ - { - displayName: 'Project', - name: 'list', - type: 'list', - placeholder: 'Select a Project...', - typeOptions: { - searchListMethod: 'getProjects', - searchable: false, - }, - }, - { - displayName: 'ID', - name: 'id', - type: 'string', - placeholder: '10000', - validation: [ - { - type: 'regex', - properties: { - regex: '([0-9]{2,})[ \t]*', - errorMessage: 'Not a valid Project ID', - }, - }, - ], - extractValue: { - type: 'regex', - regex: '^([0-9]{2,})', - }, - }, - ] -}; +import { ProjectIdField } from './ProjectIdField'; const CommonIssueFields: INodeProperties[] = [ { @@ -144,9 +105,7 @@ export const IssueFields: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - resource: [ - EasyNodeResourceType.issues, - ], + resource: [EasyNodeResourceType.issues], }, }, default: 'get-many', @@ -328,7 +287,7 @@ export const IssueFields: INodeProperties[] = [ type: 'string', default: '', placeholder: '2025-01-31', - } + }, ], }, ]; diff --git a/nodes/EasyRedmine/fields/OpportunityFields.ts b/nodes/EasyRedmine/fields/OpportunityFields.ts index 356a930..473ac4d 100644 --- a/nodes/EasyRedmine/fields/OpportunityFields.ts +++ b/nodes/EasyRedmine/fields/OpportunityFields.ts @@ -1,6 +1,7 @@ import { EasyNodeOperationType, EasyNodeResourceType } from '../Model'; import { INodeProperties } from 'n8n-workflow'; import { CustomFieldsOption } from './CustomFields'; +import { ProjectIdField } from './ProjectIdField'; export const OpportunityOptions: INodeProperties[] = [ { @@ -98,23 +99,14 @@ export const OpportunityFields: INodeProperties[] = [ }, }, { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - default: '', + ...ProjectIdField, displayOptions: { show: { - operation: [EasyNodeOperationType.create], resource: [EasyNodeResourceType.opportunities], + operation: [EasyNodeOperationType.create], }, }, - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, }, - { displayName: 'Create Options', name: 'opportunityCreateOptions', @@ -127,10 +119,7 @@ export const OpportunityFields: INodeProperties[] = [ resource: [EasyNodeResourceType.opportunities], }, }, - options: [ - ...OpportunityOptions, - CustomFieldsOption, - ], + options: [...OpportunityOptions, CustomFieldsOption], }, { @@ -161,17 +150,7 @@ export const OpportunityFields: INodeProperties[] = [ description: 'Opportunity description', }, ...OpportunityOptions, - { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', - default: '', - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, - }, + ProjectIdField, CustomFieldsOption, ], }, diff --git a/nodes/EasyRedmine/fields/ProjectIdField.ts b/nodes/EasyRedmine/fields/ProjectIdField.ts new file mode 100644 index 0000000..c1f19a1 --- /dev/null +++ b/nodes/EasyRedmine/fields/ProjectIdField.ts @@ -0,0 +1,41 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const ProjectIdField: INodeProperties = { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'resourceLocator', + description: + 'ID of the project. Choose from the list, or specify an ID using an expression.', + default: { mode: 'list', value: '' }, + modes: [ + { + displayName: 'Project', + name: 'list', + type: 'list', + placeholder: 'Select a Project...', + typeOptions: { + searchListMethod: 'getProjects', + searchable: false, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '10000', + validation: [ + { + type: 'regex', + properties: { + regex: '([0-9]{2,})[ \t]*', + errorMessage: 'Not a valid Project ID', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '^([0-9]{2,})', + }, + }, + ], +}; diff --git a/nodes/EasyRedmine/fields/TimeEntryFields.ts b/nodes/EasyRedmine/fields/TimeEntryFields.ts index 7a3b968..537c1ce 100644 --- a/nodes/EasyRedmine/fields/TimeEntryFields.ts +++ b/nodes/EasyRedmine/fields/TimeEntryFields.ts @@ -1,6 +1,7 @@ import { INodeProperties } from 'n8n-workflow'; import { EasyNodeOperationType, EasyNodeResourceType } from '../Model'; import { CustomFieldsOption } from './CustomFields'; +import { ProjectIdField } from './ProjectIdField'; const CommonTimeEntryOptions: INodeProperties[] = [ { @@ -23,16 +24,7 @@ const CommonTimeEntryOptions: INodeProperties[] = [ placeholder: 'YYYY-MM-DD', description: 'Date when the time was spent', }, - { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'options', - default: '', - description: 'ID of the project associated with the time entry. Choose from the list, or specify an ID using an expression.', - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, - }, + ProjectIdField, { displayName: 'Activity ID', name: 'activityId', @@ -57,9 +49,7 @@ export const TimeEntryFields: INodeProperties[] = [ noDataExpression: true, displayOptions: { show: { - resource: [ - EasyNodeResourceType.timeEntries - ], + resource: [EasyNodeResourceType.timeEntries], }, }, default: 'get-many', diff --git a/nodes/EasyRedmine/operations/CreateOperation.ts b/nodes/EasyRedmine/operations/CreateOperation.ts index e1b7512..64e669a 100644 --- a/nodes/EasyRedmine/operations/CreateOperation.ts +++ b/nodes/EasyRedmine/operations/CreateOperation.ts @@ -12,7 +12,7 @@ import { TimeEntryCreateOptions, UserCreateOptions, } from './CreateModel'; -import { convertToEasyDate, extractBillingOptions, sanitizeDomain } from '../utils'; +import { convertToEasyDate, extractBillingOptions, getProjectId, sanitizeDomain } from '../utils'; function convertCustomFields(options: CreateOptionsWithCustomFields): CustomField[] | undefined { return options.customFields?.field.map((customField) => ({ @@ -97,13 +97,7 @@ function createBodyForIssue(this: IExecuteFunctions, itemIndex: number): { [key: this.logger.debug(`Create issue with subject: ${JSON.stringify(options)}`); const subject = this.getNodeParameter('subject', itemIndex) as string; - let projectId = this.getNodeParameter('projectId', itemIndex) as any; - if (projectId['mode'] === 'id') { - projectId = projectId['value']; - } - else { - throw new Error('Only project by ID is supported'); - } + const projectId = getProjectId.call(this, itemIndex); const customFields = convertCustomFields(options); return { diff --git a/nodes/EasyRedmine/operations/GetOneOperation.ts b/nodes/EasyRedmine/operations/GetOneOperation.ts index 96862b8..951c478 100644 --- a/nodes/EasyRedmine/operations/GetOneOperation.ts +++ b/nodes/EasyRedmine/operations/GetOneOperation.ts @@ -1,6 +1,6 @@ import { IExecuteFunctions, IHttpRequestOptions } from 'n8n-workflow'; import { EasyNodeResourceType } from '../Model'; -import { sanitizeDomain } from '../utils/SanitizeDomain'; +import { sanitizeDomain } from '../utils'; export async function processGetOneOperation( this: IExecuteFunctions, diff --git a/nodes/EasyRedmine/utils/GetProjectId.ts b/nodes/EasyRedmine/utils/GetProjectId.ts new file mode 100644 index 0000000..7e54b0e --- /dev/null +++ b/nodes/EasyRedmine/utils/GetProjectId.ts @@ -0,0 +1,18 @@ +import { IExecuteFunctions } from 'n8n-workflow'; + +export function getProjectId(this: IExecuteFunctions, itemIndex: number): number | undefined { + let projectId = this.getNodeParameter('projectId', itemIndex) as any; + // For backward compatibility + if (typeof projectId === 'string') { + return parseInt(projectId, 10); + } else if (typeof projectId === 'number') { + return projectId; + } + + if (projectId['mode'] === 'id') { + return projectId['value']; + } else { + this.logger.error(`Project id '${JSON.stringify(projectId)}' data not supported`); + throw new Error('Only project by ID is supported'); + } +} diff --git a/nodes/EasyRedmine/utils/index.ts b/nodes/EasyRedmine/utils/index.ts index 8dd4437..553a865 100644 --- a/nodes/EasyRedmine/utils/index.ts +++ b/nodes/EasyRedmine/utils/index.ts @@ -1,4 +1,5 @@ export * from './ConvertToEasyDate'; +export * from './GetProjectId'; export * from './ExtractBillingOptions'; export * from './SanitizeDomain'; export * from './TryToParseParameterAsNumber'; From 2ce9b2e805476363598d1f96d6181b733f0e3522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Mon, 1 Sep 2025 15:51:59 +0200 Subject: [PATCH 04/10] Optimizing getProjectId method. --- nodes/EasyRedmine/operations/CreateModel.ts | 3 +- .../EasyRedmine/operations/CreateOperation.ts | 9 +++-- nodes/EasyRedmine/operations/UpdateModel.ts | 3 +- .../EasyRedmine/operations/UpdateOperation.ts | 11 +++--- nodes/EasyRedmine/utils/GetProjectId.ts | 36 +++++++++++++++---- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/nodes/EasyRedmine/operations/CreateModel.ts b/nodes/EasyRedmine/operations/CreateModel.ts index aec63f5..6906657 100644 --- a/nodes/EasyRedmine/operations/CreateModel.ts +++ b/nodes/EasyRedmine/operations/CreateModel.ts @@ -1,5 +1,6 @@ import { CustomField } from './UpdateModel'; import { BaseIssueOptions } from './BaseModel'; +import { ProjectIdValue } from '../utils'; export type IssueCreateOptions = BaseIssueOptions; @@ -22,7 +23,7 @@ export interface OpportunityCreateOptions { export interface TimeEntryCreateOptions { activityId: number | undefined; comment: string | undefined; - projectId: number | undefined; + projectId: number | ProjectIdValue | undefined; spentOn: string | undefined; userId: number | undefined; customFields: { field: CustomField[] } | undefined; diff --git a/nodes/EasyRedmine/operations/CreateOperation.ts b/nodes/EasyRedmine/operations/CreateOperation.ts index 64e669a..b91dabd 100644 --- a/nodes/EasyRedmine/operations/CreateOperation.ts +++ b/nodes/EasyRedmine/operations/CreateOperation.ts @@ -97,7 +97,8 @@ function createBodyForIssue(this: IExecuteFunctions, itemIndex: number): { [key: this.logger.debug(`Create issue with subject: ${JSON.stringify(options)}`); const subject = this.getNodeParameter('subject', itemIndex) as string; - const projectId = getProjectId.call(this, itemIndex); + let projectIdValue = this.getNodeParameter('projectId', itemIndex) as any; + const projectId = getProjectId.call(this, projectIdValue); const customFields = convertCustomFields(options); return { @@ -145,7 +146,8 @@ function createBodyForOpportunity( {}, ) as OpportunityCreateOptions; - const projectId = this.getNodeParameter('projectId', itemIndex) as string; + let projectIdValue = this.getNodeParameter('projectId', itemIndex) as any; + const projectId = getProjectId.call(this, projectIdValue); const name = this.getNodeParameter('name', itemIndex) as string; const accountId = this.getNodeParameter('accountId', itemIndex) as string; @@ -212,6 +214,7 @@ function createBodyForTimeEntry( {}, ) as TimeEntryCreateOptions; + const projectId = getProjectId.call(this, options.projectId); const hours = this.getNodeParameter('hours', itemIndex) as number; const customFields = convertCustomFields(options); @@ -221,7 +224,7 @@ function createBodyForTimeEntry( activity_id: options.activityId, comments: options.comment, custom_fields: customFields, - project_id: options.projectId, + project_id: projectId, spent_on: options.spentOn, user_id: options.userId, }, diff --git a/nodes/EasyRedmine/operations/UpdateModel.ts b/nodes/EasyRedmine/operations/UpdateModel.ts index 3f7c52c..b553422 100644 --- a/nodes/EasyRedmine/operations/UpdateModel.ts +++ b/nodes/EasyRedmine/operations/UpdateModel.ts @@ -1,4 +1,5 @@ import { BaseIssueOptions } from './BaseModel'; +import { ProjectIdValue } from '../utils'; export interface CustomField { id: number; @@ -24,7 +25,7 @@ export interface OpportunityUpdateOptions { externalAssignedToId: number | undefined; price: number | undefined; contractDate: string | undefined; - projectId: number | undefined; + projectId: number | ProjectIdValue | undefined; customFields: { field: CustomField[] } | undefined; } diff --git a/nodes/EasyRedmine/operations/UpdateOperation.ts b/nodes/EasyRedmine/operations/UpdateOperation.ts index 60815a4..05c23f2 100644 --- a/nodes/EasyRedmine/operations/UpdateOperation.ts +++ b/nodes/EasyRedmine/operations/UpdateOperation.ts @@ -12,7 +12,7 @@ import { UpdateOptionsWithCustomFields, UserUpdateOptions, } from './UpdateModel'; -import { convertToEasyDate, extractBillingOptions, sanitizeDomain } from '../utils'; +import { convertToEasyDate, extractBillingOptions, getProjectId, sanitizeDomain } from '../utils'; function convertCustomFields(options: UpdateOptionsWithCustomFields): CustomField[] | undefined { return options.customFields?.field.map((customField) => ({ @@ -23,6 +23,7 @@ function convertCustomFields(options: UpdateOptionsWithCustomFields): CustomFiel function updateBodyForIssue(this: IExecuteFunctions, itemIndex: number): { [key: string]: any } { const options = this.getNodeParameter('issueUpdateOptions', itemIndex, {}) as IssueUpdateOptions; + const projectId = getProjectId.call(this, options.projectId); this.logger.debug(`Update issue with subject: ${JSON.stringify(options)}`); @@ -31,7 +32,7 @@ function updateBodyForIssue(this: IExecuteFunctions, itemIndex: number): { [key: issue: { subject: options.subject, description: options.description, - projectId: options.projectId, + projectId, parent_issue_id: options.parentIssueId, assigned_to_id: options.assignedToId, estimated_hours: options.estimatedHours, @@ -74,6 +75,7 @@ function updateBodyForOpportunity( ) as OpportunityUpdateOptions; const customFields = convertCustomFields(options); + const projectId = getProjectId.call(this, options.projectId); return { easy_crm_case: { @@ -87,7 +89,7 @@ function updateBodyForOpportunity( price: options.price, contract_date: options.contractDate, - project_id: options.projectId, + project_id: projectId, }, }; } @@ -195,6 +197,7 @@ function updateBodyForTimeEntry( {}, ) as TimeEntryUpdateOptions; + const projectId = getProjectId.call(this, options.projectId); const customFields = convertCustomFields(options); return { @@ -202,7 +205,7 @@ function updateBodyForTimeEntry( comments: options.comment, hours: options.hours, spent_on: options.spentOn, - project_id: options.projectId, + project_id: projectId, activity_id: options.activityId, user_id: options.userId, custom_fields: customFields, diff --git a/nodes/EasyRedmine/utils/GetProjectId.ts b/nodes/EasyRedmine/utils/GetProjectId.ts index 7e54b0e..5aea9a7 100644 --- a/nodes/EasyRedmine/utils/GetProjectId.ts +++ b/nodes/EasyRedmine/utils/GetProjectId.ts @@ -1,18 +1,40 @@ import { IExecuteFunctions } from 'n8n-workflow'; -export function getProjectId(this: IExecuteFunctions, itemIndex: number): number | undefined { - let projectId = this.getNodeParameter('projectId', itemIndex) as any; +export interface ProjectIdValue { + mode: string; + value: string | number; +} + +export function getProjectId( + this: IExecuteFunctions, + projectId: ProjectIdValue | number | string | undefined, +): number | undefined { + if (typeof projectId === 'undefined') { + return undefined; + } + // For backward compatibility if (typeof projectId === 'string') { - return parseInt(projectId, 10); + const result = parseInt(projectId, 10); + if (isNaN(result)) { + return undefined; + } + return result; } else if (typeof projectId === 'number') { return projectId; } - if (projectId['mode'] === 'id') { - return projectId['value']; + if (!projectId) { + return undefined; + } + + if (typeof projectId.value === 'string') { + const result = parseInt(projectId.value); + if (isNaN(result)) { + return undefined; + } + return result; } else { - this.logger.error(`Project id '${JSON.stringify(projectId)}' data not supported`); - throw new Error('Only project by ID is supported'); + return projectId.value; } } From 4fda08476e468d8b48fe37fbffcaa5cf96566501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Tue, 2 Sep 2025 09:48:56 +0200 Subject: [PATCH 05/10] Implement basic search for projects. --- README.md | 4 ++- changelog.md | 1 + nodes/EasyRedmine/fields/ProjectFields.ts | 31 +++++++++++++++++++ .../EasyRedmine/operations/SearchOperation.ts | 17 ++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 67d142c..074ae7a 100644 --- a/README.md +++ b/README.md @@ -253,8 +253,10 @@ Personal contacts are CRM entities that represent a person. The entity name is ` Projects are used to manage project tasks. The entity name is `project`. **Operations** -* *Get one** - returns a detailed view of a single project. The entity is specified by its ID. +* **Get one** - returns a detailed view of a single project. The entity is specified by its ID. * **Get many** - returns a list of projects. You should use easy query id to specify the filter. +* **Search** - searches for projects with filtering options. + - Query - text search query to filter projects ## Time entries diff --git a/changelog.md b/changelog.md index 2d6995c..47c6584 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ # 0.5.0 (Unreleased) - Add get and get-many operations for the products entity. +- Add search operation support for issue, project entities with query parameter. # 0.4.2 (2025-08-29) diff --git a/nodes/EasyRedmine/fields/ProjectFields.ts b/nodes/EasyRedmine/fields/ProjectFields.ts index 4a85a6d..1d50c36 100644 --- a/nodes/EasyRedmine/fields/ProjectFields.ts +++ b/nodes/EasyRedmine/fields/ProjectFields.ts @@ -1,5 +1,7 @@ import { INodeProperties } from 'n8n-workflow'; import { EasyNodeOperationType, EasyNodeResourceType } from '../Model'; +import { ProjectIdField } from './ProjectIdField'; +import { CustomFieldsOption } from './CustomFields'; export const ProjectFields: INodeProperties[] = [ { @@ -26,6 +28,12 @@ export const ProjectFields: INodeProperties[] = [ value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search for multiple projects', + value: EasyNodeOperationType.search, + action: 'Search', + }, ], }, @@ -61,4 +69,27 @@ export const ProjectFields: INodeProperties[] = [ }, default: '', }, + + { + displayName: 'Update Fields', + name: 'issueUpdateOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.update], + resource: [EasyNodeResourceType.projects], + }, + }, + options: [ + { + displayName: 'Free Text', + name: 'query', + type: 'string', + default: '', + description: 'Free text query to search for projects', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts index 5bd0e0c..2cacb19 100644 --- a/nodes/EasyRedmine/operations/SearchOperation.ts +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -32,6 +32,20 @@ function enhanceIssueRequestOptions( } } +function enhanceProjectRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('projectSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + export async function processSearchOperation( this: IExecuteFunctions, resource: EasyNodeResourceType, @@ -70,6 +84,9 @@ export async function processSearchOperation( case EasyNodeResourceType.issues: enhanceIssueRequestOptions.call(this, itemIndex, options); break; + case EasyNodeResourceType.projects: + enhanceProjectRequestOptions.call(this, itemIndex, options); + break; default: throw new Error('Not implemented!'); } From b06a19f6c1e69a9ae8c31f3dd01ee968d429f959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Tue, 2 Sep 2025 11:29:51 +0200 Subject: [PATCH 06/10] Added user searching, fixing project queries --- README.md | 8 +++ changelog.md | 2 +- nodes/EasyRedmine/fields/ProjectFields.ts | 8 +-- nodes/EasyRedmine/fields/UserFields.ts | 72 +++++++++++++++++++ .../EasyRedmine/operations/SearchOperation.ts | 39 ++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 074ae7a..deaae25 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,14 @@ Users are Easy Redmine users. The entity name is `user`. - **Get one** - returns a detailed view of a single user. The entity is specified by its ID. - **Get many** - returns a list of entities. You should use easy query id to specify the filter. +- **Search** - searches for users with filtering options. + - Email - search by email address (partial match) + - First name - search by first name (partial match) + - Last name - search by last name (partial match) + - Login - search by login username (partial match) + - Status - filter by user status (integer value) + - Last login time from - filter users who logged in after this date + - Last login time to - filter users who logged in before this date - **Create** - creates a new user - Login (required) - First name (required) diff --git a/changelog.md b/changelog.md index 47c6584..34fa1c4 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,7 @@ # 0.5.0 (Unreleased) - Add get and get-many operations for the products entity. -- Add search operation support for issue, project entities with query parameter. +- Add search operation support for issue, project, user entities with query parameter and additional filtering options. # 0.4.2 (2025-08-29) diff --git a/nodes/EasyRedmine/fields/ProjectFields.ts b/nodes/EasyRedmine/fields/ProjectFields.ts index 1d50c36..3847ef6 100644 --- a/nodes/EasyRedmine/fields/ProjectFields.ts +++ b/nodes/EasyRedmine/fields/ProjectFields.ts @@ -1,7 +1,5 @@ import { INodeProperties } from 'n8n-workflow'; import { EasyNodeOperationType, EasyNodeResourceType } from '../Model'; -import { ProjectIdField } from './ProjectIdField'; -import { CustomFieldsOption } from './CustomFields'; export const ProjectFields: INodeProperties[] = [ { @@ -71,14 +69,14 @@ export const ProjectFields: INodeProperties[] = [ }, { - displayName: 'Update Fields', - name: 'issueUpdateOptions', + displayName: 'Search Fields', + name: 'projectSearchOptions', type: 'collection', placeholder: 'Add option', default: {}, displayOptions: { show: { - operation: [EasyNodeOperationType.update], + operation: [EasyNodeOperationType.search], resource: [EasyNodeResourceType.projects], }, }, diff --git a/nodes/EasyRedmine/fields/UserFields.ts b/nodes/EasyRedmine/fields/UserFields.ts index 1b788da..ffce5d5 100644 --- a/nodes/EasyRedmine/fields/UserFields.ts +++ b/nodes/EasyRedmine/fields/UserFields.ts @@ -27,6 +27,12 @@ export const UserFields: INodeProperties[] = [ value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search users', + value: EasyNodeOperationType.search, + action: 'Search', + }, { name: 'Create', description: 'Create user', @@ -196,4 +202,70 @@ export const UserFields: INodeProperties[] = [ CustomFieldsOption, ], }, + + { + displayName: 'Search Options', + name: 'userSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.users], + }, + }, + options: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + placeholder: 'name@email.com', + default: '', + description: 'Search by email address (partial match)', + }, + { + displayName: 'First Name', + name: 'firstname', + type: 'string', + default: '', + description: 'Search by first name (partial match)', + }, + { + displayName: 'Last Login Time From', + name: 'lastLoginTimeFrom', + type: 'dateTime', + default: '', + description: 'Filter users who logged in after this date', + }, + { + displayName: 'Last Login Time To', + name: 'lastLoginTimeTo', + type: 'dateTime', + default: '', + description: 'Filter users who logged in before this date', + }, + { + displayName: 'Last Name', + name: 'lastname', + type: 'string', + default: '', + description: 'Search by last name (partial match)', + }, + { + displayName: 'Login', + name: 'login', + type: 'string', + default: '', + description: 'Search by login username (partial match)', + }, + { + displayName: 'Status', + name: 'status', + type: 'number', + default: '', + description: 'Filter by user status (integer value)', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts index 2cacb19..2f4400f 100644 --- a/nodes/EasyRedmine/operations/SearchOperation.ts +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -46,6 +46,42 @@ function enhanceProjectRequestOptions( } } +function enhanceUserRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('userSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.email) { + qs['f[mail]'] = `~${options.email}`; + } + + if (options.firstname) { + qs['f[firstname]'] = `~${options.firstname}`; + } + + if (options.lastname) { + qs['f[lastname]'] = `~${options.lastname}`; + } + + if (options.login) { + qs['f[login]'] = `~${options.login}`; + } + + if (options.status !== undefined) { + qs['f[status]'] = options.status; + } + + if (options.lastLoginTimeFrom || options.lastLoginTimeTo) { + const timeFrom = options.lastLoginTimeFrom ?? '2000-01-01'; + const timeTo = options.lastLoginTimeTo ?? '2040-01-01'; + qs['f[last_login_on]'] = `${timeFrom}|${timeTo}`; + } +} + export async function processSearchOperation( this: IExecuteFunctions, resource: EasyNodeResourceType, @@ -87,6 +123,9 @@ export async function processSearchOperation( case EasyNodeResourceType.projects: enhanceProjectRequestOptions.call(this, itemIndex, options); break; + case EasyNodeResourceType.users: + enhanceUserRequestOptions.call(this, itemIndex, options); + break; default: throw new Error('Not implemented!'); } From 4564518ca139155f258cabce1d1346610ef1c9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Tue, 2 Sep 2025 11:42:52 +0200 Subject: [PATCH 07/10] Adding limits to user and project searches --- nodes/EasyRedmine/fields/ProjectFields.ts | 51 +++++++++++++++++++++++ nodes/EasyRedmine/fields/UserFields.ts | 51 +++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/nodes/EasyRedmine/fields/ProjectFields.ts b/nodes/EasyRedmine/fields/ProjectFields.ts index 3847ef6..6d5a3f4 100644 --- a/nodes/EasyRedmine/fields/ProjectFields.ts +++ b/nodes/EasyRedmine/fields/ProjectFields.ts @@ -90,4 +90,55 @@ export const ProjectFields: INodeProperties[] = [ }, ], }, + + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.projects], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.projects], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'Max number of results to return', + }, + + { + displayName: 'Offset', + name: 'offset', + type: 'number', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.projects], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Number of results to skip', + }, ]; diff --git a/nodes/EasyRedmine/fields/UserFields.ts b/nodes/EasyRedmine/fields/UserFields.ts index ffce5d5..23c77c6 100644 --- a/nodes/EasyRedmine/fields/UserFields.ts +++ b/nodes/EasyRedmine/fields/UserFields.ts @@ -268,4 +268,55 @@ export const UserFields: INodeProperties[] = [ }, ], }, + + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.users], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.users], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'Max number of results to return', + }, + + { + displayName: 'Offset', + name: 'offset', + type: 'number', + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.users], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'Number of results to skip', + }, ]; From 52c729253cac264cb10964d065704d2789ccae08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Tue, 2 Sep 2025 15:03:01 +0200 Subject: [PATCH 08/10] Adding time entries search operation --- nodes/EasyRedmine/EasyRedmine.node.ts | 9 ++-- nodes/EasyRedmine/fields/ProjectFields.ts | 51 ------------------ nodes/EasyRedmine/fields/TimeEntryFields.ts | 50 +++++++++++++++++ nodes/EasyRedmine/fields/UserFields.ts | 54 +------------------ .../EasyRedmine/operations/SearchOperation.ts | 27 ++++++++++ 5 files changed, 85 insertions(+), 106 deletions(-) diff --git a/nodes/EasyRedmine/EasyRedmine.node.ts b/nodes/EasyRedmine/EasyRedmine.node.ts index ebd4b57..9b0e995 100644 --- a/nodes/EasyRedmine/EasyRedmine.node.ts +++ b/nodes/EasyRedmine/EasyRedmine.node.ts @@ -180,7 +180,7 @@ export class EasyRedmine implements INodeType { description: 'Whether to return all results or only up to a given limit', displayOptions: { show: { - operation: [EasyNodeOperationType.getMany], + operation: [EasyNodeOperationType.getMany, EasyNodeOperationType.search], }, }, }, @@ -190,9 +190,12 @@ export class EasyRedmine implements INodeType { type: 'number', default: 0, description: 'Result offset', + typeOptions: { + minValue: 0, + }, displayOptions: { show: { - operation: [EasyNodeOperationType.getMany], + operation: [EasyNodeOperationType.getMany, EasyNodeOperationType.search], returnAll: [false], }, }, @@ -208,7 +211,7 @@ export class EasyRedmine implements INodeType { description: 'Max number of results to return', displayOptions: { show: { - operation: [EasyNodeOperationType.getMany], + operation: [EasyNodeOperationType.getMany, EasyNodeOperationType.search], returnAll: [false], }, }, diff --git a/nodes/EasyRedmine/fields/ProjectFields.ts b/nodes/EasyRedmine/fields/ProjectFields.ts index 6d5a3f4..3847ef6 100644 --- a/nodes/EasyRedmine/fields/ProjectFields.ts +++ b/nodes/EasyRedmine/fields/ProjectFields.ts @@ -90,55 +90,4 @@ export const ProjectFields: INodeProperties[] = [ }, ], }, - - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.projects], - }, - }, - default: false, - description: 'Whether to return all results or only up to a given limit', - }, - - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.projects], - returnAll: [false], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 50, - description: 'Max number of results to return', - }, - - { - displayName: 'Offset', - name: 'offset', - type: 'number', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.projects], - returnAll: [false], - }, - }, - typeOptions: { - minValue: 0, - }, - default: 0, - description: 'Number of results to skip', - }, ]; diff --git a/nodes/EasyRedmine/fields/TimeEntryFields.ts b/nodes/EasyRedmine/fields/TimeEntryFields.ts index 537c1ce..e2fa103 100644 --- a/nodes/EasyRedmine/fields/TimeEntryFields.ts +++ b/nodes/EasyRedmine/fields/TimeEntryFields.ts @@ -66,6 +66,12 @@ export const TimeEntryFields: INodeProperties[] = [ value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search time entries', + value: EasyNodeOperationType.search, + action: 'Search', + }, { name: 'Create', description: 'Create entity', @@ -166,4 +172,48 @@ export const TimeEntryFields: INodeProperties[] = [ CustomFieldsOption, ], }, + + { + displayName: 'Search Options', + name: 'timeEntrySearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.timeEntries], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Text search query to filter time entries', + }, + { + displayName: 'Project ID', + name: 'projectId', + type: 'number', + default: '', + description: 'Filter by project ID', + }, + { + displayName: 'From', + name: 'from', + type: 'dateTime', + default: '', + description: 'Filter time entries from this date', + }, + { + displayName: 'To', + name: 'to', + type: 'dateTime', + default: '', + description: 'Filter time entries to this date', + }, + ], + } ]; diff --git a/nodes/EasyRedmine/fields/UserFields.ts b/nodes/EasyRedmine/fields/UserFields.ts index 23c77c6..4ecc401 100644 --- a/nodes/EasyRedmine/fields/UserFields.ts +++ b/nodes/EasyRedmine/fields/UserFields.ts @@ -65,7 +65,8 @@ export const UserFields: INodeProperties[] = [ displayName: 'EasyRedmine User Query Name or ID', name: 'userQueryId', type: 'options', - description: 'Choose a query to filter the results. Choose from the list, or specify an ID using an expression. Choose from the list, or specify an ID using an expression.', + description: + 'Choose a query to filter the results. Choose from the list, or specify an ID using an expression. Choose from the list, or specify an ID using an expression.', displayOptions: { show: { resource: [EasyNodeResourceType.users], @@ -268,55 +269,4 @@ export const UserFields: INodeProperties[] = [ }, ], }, - - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.users], - }, - }, - default: false, - description: 'Whether to return all results or only up to a given limit', - }, - - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.users], - returnAll: [false], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 100, - }, - default: 50, - description: 'Max number of results to return', - }, - - { - displayName: 'Offset', - name: 'offset', - type: 'number', - displayOptions: { - show: { - operation: [EasyNodeOperationType.search], - resource: [EasyNodeResourceType.users], - returnAll: [false], - }, - }, - typeOptions: { - minValue: 0, - }, - default: 0, - description: 'Number of results to skip', - }, ]; diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts index 2f4400f..da266dc 100644 --- a/nodes/EasyRedmine/operations/SearchOperation.ts +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -82,6 +82,30 @@ function enhanceUserRequestOptions( } } +function enhanceTimeEntryRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('timeEntrySearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } + + if (options.projectId) { + qs['f[project_id]'] = options.projectId; + } + + if (options.from || options.to) { + const dateFrom = options.from ?? '2000-01-01'; + const dateTo = options.to ?? '2040-01-01'; + qs['f[spent_on]'] = `${dateFrom}|${dateTo}`; + } +} + export async function processSearchOperation( this: IExecuteFunctions, resource: EasyNodeResourceType, @@ -126,6 +150,9 @@ export async function processSearchOperation( case EasyNodeResourceType.users: enhanceUserRequestOptions.call(this, itemIndex, options); break; + case EasyNodeResourceType.timeEntries: + enhanceTimeEntryRequestOptions.call(this, itemIndex, options); + break; default: throw new Error('Not implemented!'); } From 67157dac57b494bc704479db90399aeaec2de7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Wed, 3 Sep 2025 10:05:09 +0200 Subject: [PATCH 09/10] Leads options fields --- nodes/EasyRedmine/EasyRedmine.node.ts | 6 +++++ nodes/EasyRedmine/fields/LeadFields.ts | 23 +++++++++++++++++++ .../EasyRedmine/operations/SearchOperation.ts | 5 ++++ 3 files changed, 34 insertions(+) diff --git a/nodes/EasyRedmine/EasyRedmine.node.ts b/nodes/EasyRedmine/EasyRedmine.node.ts index 9b0e995..9f9a669 100644 --- a/nodes/EasyRedmine/EasyRedmine.node.ts +++ b/nodes/EasyRedmine/EasyRedmine.node.ts @@ -141,6 +141,12 @@ export class EasyRedmine implements INodeType { value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search entities', + value: EasyNodeOperationType.search, + action: 'Search', + }, { name: 'Add Comment', description: 'Add a comment to entity', diff --git a/nodes/EasyRedmine/fields/LeadFields.ts b/nodes/EasyRedmine/fields/LeadFields.ts index 2f1b2b7..88198ae 100644 --- a/nodes/EasyRedmine/fields/LeadFields.ts +++ b/nodes/EasyRedmine/fields/LeadFields.ts @@ -83,4 +83,27 @@ export const LeadFields: INodeProperties[] = [ }, options: [...CommonLeadOptions, CustomFieldsOption], }, + + { + displayName: 'Search Options', + name: 'leadSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.leads], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Search query for leads', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts index da266dc..0c044c1 100644 --- a/nodes/EasyRedmine/operations/SearchOperation.ts +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -153,6 +153,11 @@ export async function processSearchOperation( case EasyNodeResourceType.timeEntries: enhanceTimeEntryRequestOptions.call(this, itemIndex, options); break; + // personalContacts + // leads + // opportunities + // accounts + // attendances default: throw new Error('Not implemented!'); } From 816a7f3bead98ea1b8ff329453de2c172ab34cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Nova=CC=81k?= Date: Wed, 3 Sep 2025 14:37:39 +0200 Subject: [PATCH 10/10] Other n8n nodes --- nodes/EasyRedmine/fields/AccountFields.ts | 23 +++++ nodes/EasyRedmine/fields/AttendanceFields.ts | 29 ++++++ nodes/EasyRedmine/fields/OpportunityFields.ts | 23 +++++ .../fields/PersonalContactFields.ts | 29 ++++++ .../EasyRedmine/operations/SearchOperation.ts | 90 +++++++++++++++++-- 5 files changed, 189 insertions(+), 5 deletions(-) diff --git a/nodes/EasyRedmine/fields/AccountFields.ts b/nodes/EasyRedmine/fields/AccountFields.ts index f86bc88..64e1f62 100644 --- a/nodes/EasyRedmine/fields/AccountFields.ts +++ b/nodes/EasyRedmine/fields/AccountFields.ts @@ -300,4 +300,27 @@ export const AccountFields: INodeProperties[] = [ }, options: [...BillingAccountOptions], }, + + { + displayName: 'Search Options', + name: 'accountSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.accounts], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Search query for accounts', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/fields/AttendanceFields.ts b/nodes/EasyRedmine/fields/AttendanceFields.ts index 28a0bc4..770a5bb 100644 --- a/nodes/EasyRedmine/fields/AttendanceFields.ts +++ b/nodes/EasyRedmine/fields/AttendanceFields.ts @@ -56,6 +56,12 @@ export const AttendanceFields: INodeProperties[] = [ value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search attendance entities', + value: EasyNodeOperationType.search, + action: 'Search', + }, { name: 'Create', description: 'Create attendance entity', @@ -144,4 +150,27 @@ export const AttendanceFields: INodeProperties[] = [ }, options: [...AttendanceUpdateOptions, ...CommonAttendanceOptions], }, + + { + displayName: 'Search Options', + name: 'attendanceSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.attendances], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Search query for attendances', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/fields/OpportunityFields.ts b/nodes/EasyRedmine/fields/OpportunityFields.ts index 473ac4d..5839bfe 100644 --- a/nodes/EasyRedmine/fields/OpportunityFields.ts +++ b/nodes/EasyRedmine/fields/OpportunityFields.ts @@ -154,4 +154,27 @@ export const OpportunityFields: INodeProperties[] = [ CustomFieldsOption, ], }, + + { + displayName: 'Search Options', + name: 'opportunitySearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.opportunities], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Search query for opportunities', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/fields/PersonalContactFields.ts b/nodes/EasyRedmine/fields/PersonalContactFields.ts index 7576606..0ee1d80 100644 --- a/nodes/EasyRedmine/fields/PersonalContactFields.ts +++ b/nodes/EasyRedmine/fields/PersonalContactFields.ts @@ -87,6 +87,12 @@ export const PersonalContactFields: INodeProperties[] = [ value: EasyNodeOperationType.getMany, action: 'Get many', }, + { + name: 'Search', + description: 'Search personal contacts', + value: EasyNodeOperationType.search, + action: 'Search', + }, { name: 'Add Comment', description: 'Add comment to personal contact', @@ -211,4 +217,27 @@ export const PersonalContactFields: INodeProperties[] = [ }, options: [...PersonalContactUpdateOptionalFields, CustomFieldsOption], }, + + { + displayName: 'Search Options', + name: 'personalContactSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.personalContacts], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'Search query for personal contacts', + }, + ], + }, ]; diff --git a/nodes/EasyRedmine/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts index 0c044c1..9d0c4d0 100644 --- a/nodes/EasyRedmine/operations/SearchOperation.ts +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -106,6 +106,76 @@ function enhanceTimeEntryRequestOptions( } } +function enhanceLeadRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('leadSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + +function enhanceOpportunityRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('opportunitySearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + +function enhancePersonalContactRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('personalContactSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + +function enhanceAccountRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('accountSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + +function enhanceAttendanceRequestOptions( + this: IExecuteFunctions, + itemIndex: number, + req: IHttpRequestOptions, +) { + const options = this.getNodeParameter('attendanceSearchOptions', itemIndex, {}) as any; + + const qs = req.qs as IDataObject; + + if (options.query) { + qs.easy_query_q = options.query; + } +} + export async function processSearchOperation( this: IExecuteFunctions, resource: EasyNodeResourceType, @@ -153,11 +223,21 @@ export async function processSearchOperation( case EasyNodeResourceType.timeEntries: enhanceTimeEntryRequestOptions.call(this, itemIndex, options); break; - // personalContacts - // leads - // opportunities - // accounts - // attendances + case EasyNodeResourceType.leads: + enhanceLeadRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.opportunities: + enhanceOpportunityRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.personalContacts: + enhancePersonalContactRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.accounts: + enhanceAccountRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.attendances: + enhanceAttendanceRequestOptions.call(this, itemIndex, options); + break; default: throw new Error('Not implemented!'); }