diff --git a/README.md b/README.md index 67d142c..deaae25 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 @@ -289,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 2d6995c..34fa1c4 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, user entities with query parameter and additional filtering options. # 0.4.2 (2025-08-29) diff --git a/nodes/EasyRedmine/EasyRedmine.node.ts b/nodes/EasyRedmine/EasyRedmine.node.ts index 8e9711f..9f9a669 100644 --- a/nodes/EasyRedmine/EasyRedmine.node.ts +++ b/nodes/EasyRedmine/EasyRedmine.node.ts @@ -2,26 +2,32 @@ import { IDataObject, IExecuteFunctions, + ILoadOptionsFunctions, INodeExecutionData, INodeType, INodeTypeDescription, NodeOperationError, + INodeListSearchResult, } from 'n8n-workflow'; import { EasyNodeOperationType, EasyNodeResourceType } from './Model'; -import { processGetManyOperation } from './operations/GetManyOperation'; -import { processGetOneOperation } from './operations/GetOneOperation'; -import { addCommentOperation } from './operations/AddCommentOperation'; -import { updateOperation } from './operations/UpdateOperation'; +import { + processGetManyOperation, + processGetOneOperation, + addCommentOperation, + updateOperation, + createOperation, + processSearchOperation, +} from './operations'; import { IssueFields } from './fields/IssueFields'; import { LeadFields } from './fields/LeadFields'; import { OpportunityFields } from './fields/OpportunityFields'; import { AccountFields } from './fields/AccountFields'; import { PersonalContactFields } from './fields/PersonalContactFields'; import { UserFields } from './fields/UserFields'; -import { createOperation } from './operations/CreateOperation'; import { TimeEntryFields } from './fields/TimeEntryFields'; import { AttendanceFields } from './fields/AttendanceFields'; import { loadOptions } from './LoadOptions'; +import { EasyRedmineClient } from './client'; import { ProjectFields } from './fields/ProjectFields'; /** @@ -44,6 +50,7 @@ export class EasyRedmine implements INodeType { }, inputs: ['main'], outputs: ['main'], + usableAsTool: true, credentials: [ { name: 'easyRedmineApi', @@ -114,7 +121,6 @@ export class EasyRedmine implements INodeType { displayOptions: { show: { resource: [ - EasyNodeResourceType.issues, EasyNodeResourceType.leads, EasyNodeResourceType.opportunities, EasyNodeResourceType.accounts, @@ -135,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', @@ -174,7 +186,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], }, }, }, @@ -184,9 +196,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], }, }, @@ -202,7 +217,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], }, }, @@ -224,6 +239,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 { @@ -242,6 +276,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/LoadOptions.ts b/nodes/EasyRedmine/LoadOptions.ts index 796d222..a7bd8c8 100644 --- a/nodes/EasyRedmine/LoadOptions.ts +++ b/nodes/EasyRedmine/LoadOptions.ts @@ -122,19 +122,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/Model.ts b/nodes/EasyRedmine/Model.ts index 0ef16d6..e4bbd6c 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/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/IssueFields.ts b/nodes/EasyRedmine/fields/IssueFields.ts index 76b0ddc..7580344 100644 --- a/nodes/EasyRedmine/fields/IssueFields.ts +++ b/nodes/EasyRedmine/fields/IssueFields.ts @@ -1,18 +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: 'options', - description: - 'ID of the project. Choose from the list, or specify an ID using an expression.', - default: '', - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, -}; +import { ProjectIdField } from './ProjectIdField'; const CommonIssueFields: INodeProperties[] = [ { @@ -109,6 +98,56 @@ 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 +246,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/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/fields/OpportunityFields.ts b/nodes/EasyRedmine/fields/OpportunityFields.ts index 356a930..5839bfe 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,18 +150,31 @@ export const OpportunityFields: INodeProperties[] = [ description: 'Opportunity description', }, ...OpportunityOptions, + ProjectIdField, + CustomFieldsOption, + ], + }, + + { + displayName: 'Search Options', + name: 'opportunitySearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + resource: [EasyNodeResourceType.opportunities], + }, + }, + options: [ { - displayName: 'Project Name or ID', - name: 'projectId', - type: 'options', - description: - 'Choose from the list, or specify an ID using an expression', + displayName: 'Query', + name: 'query', + type: 'string', default: '', - typeOptions: { - loadOptionsMethod: 'getAccessibleProjects', - }, + description: 'Search query for opportunities', }, - CustomFieldsOption, ], }, ]; 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/fields/ProjectFields.ts b/nodes/EasyRedmine/fields/ProjectFields.ts index 4a85a6d..3847ef6 100644 --- a/nodes/EasyRedmine/fields/ProjectFields.ts +++ b/nodes/EasyRedmine/fields/ProjectFields.ts @@ -26,6 +26,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 +67,27 @@ export const ProjectFields: INodeProperties[] = [ }, default: '', }, + + { + displayName: 'Search Fields', + name: 'projectSearchOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + operation: [EasyNodeOperationType.search], + 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/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..e2fa103 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', @@ -76,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', @@ -176,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 1b788da..4ecc401 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', @@ -59,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], @@ -196,4 +203,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/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 4e3cace..b91dabd 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,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 = this.getNodeParameter('projectId', itemIndex) as string; + 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, }, @@ -298,7 +301,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/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/operations/SearchOperation.ts b/nodes/EasyRedmine/operations/SearchOperation.ts new file mode 100644 index 0000000..9d0c4d0 --- /dev/null +++ b/nodes/EasyRedmine/operations/SearchOperation.ts @@ -0,0 +1,263 @@ +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; + } +} + +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; + } +} + +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}`; + } +} + +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}`; + } +} + +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, + 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; + case EasyNodeResourceType.projects: + enhanceProjectRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.users: + enhanceUserRequestOptions.call(this, itemIndex, options); + break; + case EasyNodeResourceType.timeEntries: + enhanceTimeEntryRequestOptions.call(this, itemIndex, options); + break; + 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!'); + } + + 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; +} 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/operations/index.ts b/nodes/EasyRedmine/operations/index.ts new file mode 100644 index 0000000..5543010 --- /dev/null +++ b/nodes/EasyRedmine/operations/index.ts @@ -0,0 +1,6 @@ +export * from './AddCommentOperation'; +export * from './CreateOperation'; +export * from './GetManyOperation'; +export * from './GetOneOperation'; +export * from './UpdateOperation'; +export * from './SearchOperation'; diff --git a/nodes/EasyRedmine/utils/GetProjectId.ts b/nodes/EasyRedmine/utils/GetProjectId.ts new file mode 100644 index 0000000..5aea9a7 --- /dev/null +++ b/nodes/EasyRedmine/utils/GetProjectId.ts @@ -0,0 +1,40 @@ +import { IExecuteFunctions } from 'n8n-workflow'; + +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') { + const result = parseInt(projectId, 10); + if (isNaN(result)) { + return undefined; + } + return result; + } else if (typeof projectId === 'number') { + return projectId; + } + + if (!projectId) { + return undefined; + } + + if (typeof projectId.value === 'string') { + const result = parseInt(projectId.value); + if (isNaN(result)) { + return undefined; + } + return result; + } else { + return projectId.value; + } +} 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';