diff --git a/packages/task/src/index.ts b/packages/task/src/index.ts index 5c084523..716a1bba 100644 --- a/packages/task/src/index.ts +++ b/packages/task/src/index.ts @@ -31,4 +31,6 @@ export { CreateTaskParams, createTask } from "./tasks/create-task/create-task"; export { CompleteTaskParams, completeTask } from "./tasks/complete-task/complete-task"; export { DeleteTaskParams, deleteTask } from "./tasks/delete-task/delete-task"; export { getTaskCount } from "./tasks/get-task-count/get-task-count"; -export { UpdateTaskParams, updateTask } from "./tasks/update-task/update-task"; \ No newline at end of file +export { UpdateTaskParams, updateTask } from "./tasks/update-task/update-task"; +export { GetTaskParams, getTask } from "./tasks/get-task/get-task"; +export { SearchTasksParams, SearchTasksPage, searchTasks, buildRangeParameter } from "./tasks/search-tasks/search-tasks"; \ No newline at end of file diff --git a/packages/task/src/tasks/get-task/get-task.spec.ts b/packages/task/src/tasks/get-task/get-task.spec.ts new file mode 100644 index 00000000..178ab24b --- /dev/null +++ b/packages/task/src/tasks/get-task/get-task.spec.ts @@ -0,0 +1,49 @@ +import {_getTaskDefaultTransformFunction, _getTaskFactory, GetTaskParams} from "./get-task"; +import {DvelopContext} from "@dvelop-sdk/core"; +import {HttpResponse} from "../../utils/http"; + +describe("getTaskFactory", () => { + let mockHttpRequestFunction = jest.fn(); + let mockTransformFunction = jest.fn(); + + let params : GetTaskParams = { + taskId: "SomeTestId" + }; + let context: DvelopContext = { + systemBaseUri: "someBaseUri" + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should map params correctly", async () => { + const getTask = _getTaskFactory(mockHttpRequestFunction, mockTransformFunction); + + await getTask(context, params); + expect(mockHttpRequestFunction).toHaveBeenCalledWith(context, { + method: "GET", + url: "/task/tasks/SomeTestId" + }); + }); + + it("should transform response", async () => { + mockHttpRequestFunction.mockResolvedValue({ data: { + "id" : "SomeTestId", + "subject" : "My subject", + "assignees" : ["bob"], + "sender" : "alice", + "receiveDate" : "2024-07-28T12:12:12.000Z" + }} as unknown as HttpResponse); + const getTask = _getTaskFactory(mockHttpRequestFunction, _getTaskDefaultTransformFunction); + + const task = await getTask(context, params); + + expect(task.id).toBe("SomeTestId"); + expect(task.subject).toBe("My subject"); + expect(task.assignees).toEqual(["bob"]); + expect(task.sender).toBe("alice"); + expect(task.receiveDate).toEqual(new Date("2024-07-28T12:12:12.000Z")); + }); + +}); \ No newline at end of file diff --git a/packages/task/src/tasks/get-task/get-task.ts b/packages/task/src/tasks/get-task/get-task.ts new file mode 100644 index 00000000..125ff060 --- /dev/null +++ b/packages/task/src/tasks/get-task/get-task.ts @@ -0,0 +1,156 @@ +import { DvelopContext } from "@dvelop-sdk/core"; +import { HttpConfig, HttpResponse, _defaultHttpRequestFunction } from "../../utils/http"; + +/** + * Parameters for the {@link getTask}-function. + * @category Task + */ +export interface GetTaskParams { + /** ID of the task */ + taskId: string; +} + +/** + * Response for the {@link getTask}-function. + * @category Task + */ +export interface Task { + /** Unique id of the task */ + id: string; + /** The subject of the task */ + subject: string; + /** The recipients of the task. You can specify individual users as well as groups using IDs of the Identityprovider-App */ + assignees: string[]; + /** Sender of the task */ + sender: string; + /** The correlation key ensures that only one task is created for this unique key. If a task already exists with the passed key, a new task will not be created. */ + correlationKey?: string; + /** A descriptive text of the task */ + description?: string; + /** State of the task */ + state: "OPEN" | "COMPLETED"; + /** Priority between 0 (low) and 100 (high) */ + priority?: number, + /** Receive date */ + receiveDate: Date; + /** Reminder date. If you transfer a date without a timestamp, the reminder date is the transferred date at 00:00:00. */ + reminderDate?: Date; + /** Due date of the task. If you transfer a date without a timestamp, the due date is the transferred date at 00:00:00. */ + dueDate?: Date; + /** + * Specify how long the task details should be kept after completing the task. Valid values are between 0 and 365 days. After this time has passed, the task details will be deleted automatically. + * The information is specified as a time span in days according to ISO-8601, e.g. P30D for 30 days. Specify the time span P0D if the task details should be deleted immediately after the task is completed. If you make no specification, 30 days are automatically assumed. + */ + retentionTime?: string; + /** ID of the user that completed this task. Only present if completed */ + completionUser?: string; + /** Time at which the task was completed. Only present if completed */ + completionDate?: Date; + /** The context of a task */ + context?: { + /** A technical identifier for the context */ + key?: string; + /** Type of the context */ + type?: string; + /** Display name of the context */ + name?: string; + }, + + /** Metadata for the task. See [the documentation](https://developer.d-velop.de/documentation/taskapi/en#creating-a-task) for further information */ + metadata?: { + /** A technical identifier for the metadata-field */ + key: string; + /** Label of the metadata-field */ + caption?: string; + /** Type of the metadata-field */ + type: "String" | "Number" | "Money" | "Date"; + /** Value of the metadata field. Currently, only one value is allowed per metadata-field. */ + values?: string; + }[]; + /** DmsObject that references the task. */ + dmsReferences?: { + /** ID of the repository */ + repoId: string; + /** ID of the DmsObject */ + objectId: string; + }[]; + + /** Links to the task */ + _links?: { + /** Link to this task. */ + self: { href: string; }; + /** This URI provides an editing dialog for the task. You can find further details in the section [Adding editing dialogs](https://developer.d-velop.de/documentation/taskapi/en#adding-editing-dialogs). */ + form?: { href: string; }; + /** This URI is displayed as an action in the user interface to display additional information for the user. */ + attachment?: { href: string; }; + } +} + +/** + * Default transform-function provided to the {@link getTask}-function. See [Advanced Topics](https://github.com/d-velop/dvelop-sdk-node#advanced-topics) for more information. + * @internal + * @category Task + */ +export function _getTaskDefaultTransformFunction(response: HttpResponse, _: DvelopContext, __: GetTaskParams): Task { + let task : Task; + const responseTask = response.data; + task = {...responseTask}; + + if (responseTask.receiveDate) { + task.receiveDate = new Date(responseTask.receiveDate); + } + if (responseTask.reminderDate) { + task.reminderDate = new Date(responseTask.reminderDate); + } + if (responseTask.dueDate) { + task.dueDate = new Date(responseTask.dueDate); + } + if (responseTask.completionDate) { + task.completionDate = new Date(responseTask.completionDate); + } + + return task; +} + +/** + * Factory for the {@link getTask}-function. See [Advanced Topics](https://github.com/d-velop/dvelop-sdk-node#advanced-topics) for more information. + * @typeparam T Return type of the {@link getTask}-function. A corresponding transformFunction has to be supplied. + * @internal + * @category Task + */ +export function _getTaskFactory( + httpRequestFunction: (context: DvelopContext, config: HttpConfig) => Promise, + transformFunction: (response: HttpResponse, context: DvelopContext, params: GetTaskParams) => T +): (context: DvelopContext, params: GetTaskParams) => Promise { + + return async (context: DvelopContext, params: GetTaskParams) => { + const response: HttpResponse = await httpRequestFunction(context, { + method: "GET", + url: `/task/tasks/${params.taskId}` + }); + + return transformFunction(response, context, params); + }; +} + +/** + * Get a task. + * @returns A task object + * + * ```typescript + * import { getTask } from "@dvelop-sdk/task"; + * + * const task = await getTask({ + * systemBaseUri: "https://umbrella-corp.d-velop.cloud", + * authSessionId: "dQw4w9WgXcQ" + * }, { + * id: "SomeTaskId" + * }); + * ``` + * + * @category Task + */ +/* istanbul ignore next */ +export function getTask(context: DvelopContext, params: GetTaskParams): Promise { + return _getTaskFactory(_defaultHttpRequestFunction, _getTaskDefaultTransformFunction)(context, params); +} \ No newline at end of file diff --git a/packages/task/src/tasks/search-tasks/search-tasks.spec.ts b/packages/task/src/tasks/search-tasks/search-tasks.spec.ts new file mode 100644 index 00000000..00cec70c --- /dev/null +++ b/packages/task/src/tasks/search-tasks/search-tasks.spec.ts @@ -0,0 +1,155 @@ +import {_searchTasksDefaultTransformFunctionFactory, _searchTasksFactory, buildRangeParameter, SearchTasksParams} from "./search-tasks"; +import {DvelopContext} from "@dvelop-sdk/core"; +import {HttpResponse} from "../../utils/http"; + +describe("build range parameters", () => { + + it("should create a valid range string", () => { + const result = buildRangeParameter({ + from: 10, + to: 20, + beginInclusive: true, + endInclusive: false + }); + + expect(result).toBe("[10..20)"); + }); + + it("should use inclusive search as default", () => { + const result = buildRangeParameter({ + from: 10, + to: 20 + }); + + expect(result).toBe("[10..20]"); + }); + + it("should throw when no values are present", () => { + expect(() => buildRangeParameter({})).toThrow(); + }); + + it("should work with an open range", () => { + const result = buildRangeParameter({ + to: 20 + }); + + expect(result).toBe("[..20]"); + }); + + it("should work with Date", () => { + const result = buildRangeParameter({ + from: new Date("2024-01-01T00:00:00.000Z"), + to: new Date("2025-01-01T00:00:00.000Z") + }); + + expect(result).toBe("[2024-01-01T00:00:00.000Z..2025-01-01T00:00:00.000Z]"); + }); +}); + +describe("search tasks", () => { + let mockHttpRequestFunction = jest.fn(); + let mockTransformFunction = jest.fn(); + + let context: DvelopContext = { + systemBaseUri: "someBaseUri" + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should call the correct endpoint", async () => { + const searchTasks = _searchTasksFactory(mockHttpRequestFunction, mockTransformFunction); + + let params : SearchTasksParams = {}; + + await searchTasks(context, params); + + expect(mockHttpRequestFunction).toHaveBeenCalledWith(context, { + method: "POST", + url: "/task/api/tasks/search", + data: {} + }); + }); + + it("should pass parameters", async () => { + const searchTasks = _searchTasksFactory(mockHttpRequestFunction, mockTransformFunction); + + let params : SearchTasksParams = { + pageSize: 5, + orderBy: "subject", + orderDir: "DESC", + filter: { + subject: ["test"] + } + }; + + await searchTasks(context, params); + + expect(mockHttpRequestFunction).toHaveBeenCalledWith(context, { + method: "POST", + url: "/task/api/tasks/search", + data: { + pageSize: 5, + orderBy: "subject", + orderDir: "DESC", + filter: { + subject: ["test"] + } + } + }); + }); + + it ("should transform date values", async () => { + mockHttpRequestFunction.mockResolvedValue({ data: { + tasks: [{ + id: "test1", + receiveDate: "2024-01-01T12:00:00.000Z", + dueDate: "2025-01-01T12:00:00.000Z", + reminderDate: "2026-01-01T12:00:00.000Z", + completionDate: "2027-01-01T12:00:00.000Z", + state: "COMPLETED" + }] + }} as unknown as HttpResponse); + + const transformFunction = _searchTasksDefaultTransformFunctionFactory(mockHttpRequestFunction); + const searchTasks = _searchTasksFactory(mockHttpRequestFunction, transformFunction); + + let page = await searchTasks(context, {}); + + expect(page.tasks[0].receiveDate).toStrictEqual(new Date("2024-01-01T12:00:00.000Z")); + expect(page.tasks[0].dueDate).toStrictEqual(new Date("2025-01-01T12:00:00.000Z")); + expect(page.tasks[0].reminderDate).toStrictEqual(new Date("2026-01-01T12:00:00.000Z")); + expect(page.tasks[0].completionDate).toStrictEqual(new Date("2027-01-01T12:00:00.000Z")); + }); + + it("should support paging", async () => { + mockHttpRequestFunction.mockResolvedValue({ data: { + tasks: [], + _links: { + next: {href: "/test/next"} + } + }} as unknown as HttpResponse); + + const transformFunction = _searchTasksDefaultTransformFunctionFactory(mockHttpRequestFunction); + const searchTasks = _searchTasksFactory(mockHttpRequestFunction, transformFunction); + + const params : SearchTasksParams = { + pageSize: 1 + }; + let searchTasksPage = await searchTasks(context, params); + + await searchTasksPage.getNextPage(); + + expect(mockHttpRequestFunction).toHaveBeenCalledTimes(2); + + expect(mockHttpRequestFunction).toHaveBeenCalledWith(context, { + method: "POST", + url: "/test/next", + data: { + pageSize: 1 + } + }); + + }); +}); \ No newline at end of file diff --git a/packages/task/src/tasks/search-tasks/search-tasks.ts b/packages/task/src/tasks/search-tasks/search-tasks.ts new file mode 100644 index 00000000..76091567 --- /dev/null +++ b/packages/task/src/tasks/search-tasks/search-tasks.ts @@ -0,0 +1,265 @@ +import { DvelopContext } from "@dvelop-sdk/core"; +import { HttpConfig, HttpResponse, _defaultHttpRequestFunction } from "../../utils/http"; +import {Task} from "../get-task/get-task"; + +/** + * Parameters for the {@link searchTasks}-function. + * @category Task + */ +export interface SearchTasksParams { + /** + * Number of tasks per page in the results. Maximum 100. + */ + pageSize?: number; + /** + * Value used to sort the results. Permitted values: received, subject, priority and dueDate. + */ + orderBy?: "received" | "subject" | "priority" | "dueDate" | string; + /** + * Specified sort order. Permitted values: ASC for sorting in ascending order and DESC for sorting in descending order. + */ + orderDir?: "ASC" | "DESC"; + /** + * Optional search criteria. + */ + filter?: { + /** + * Search for subject. Minimum 1 character, maximum 255. + */ + subject?: string[]; + /** + * Search for recipient. Minimum 1 character, maximum 255. + */ + assignee?: string[]; + /** + * Search for sender. Minimum 1 character, maximum 255. + */ + sender?: string[]; + /** + * Search for state. Permitted values: OPEN and COMPLETED. + */ + state?: ("OPEN" | "COMPLETED")[], + /** + * Search for context key. Max. 255 characters. + */ + contextKey?: string[]; + /** + * Search for context name. Max. 255 characters. + */ + contextName?: string[]; + /** + * Search for context type. Max. 255 characters. + */ + contextType?: string[]; + /** + * Search for DMS repository ID. Minimum 1 character, maximum 100. + */ + dmsRepoId?: string[]; + /** + * Search for DMS object ID. Minimum 1 character, maximum 100. + */ + dmsObjectId?: string[]; + /** + * Search for attachment. Minimum 1 character, maximum 1.000. + */ + attachment?: string[]; + /** + * Search for user who completed the task. Minimum 1 character, maximum 255. + */ + completionUser?: string[]; + /** + * Search for priority. Between 0 and 100. You can search for individual priorities (.e.g.: [50]) or priority ranges (e.g. [“[5 to 50]”]). + */ + priority?: (number | string)[]; + /** + * Search for date received. Date format according to RFC3339. You can only search for a range. + */ + received?: string[]; + /** + * Search for due date. Date format according to RFC3339. You can only search for a range. + */ + dueDate?: string[]; + /** + * Search for reminder date. Date format according to RFC3339. You can only search for a range. + */ + reminderDate?: string[]; + /** + * Search for completion date. Date format according to RFC3339. You can only search for a range. + */ + completionDate?: string[]; + /** + * Search for metadata. Can contain any quantity of metadata. + * You can only use metadata of the type “STRING” for the search. + */ + metadata?: { + /** + * Search entry for an individual piece of metadata. Minimum 1 character, maximum 255. The key contains a minimum of 1 character, maximum 255. + */ + [key: string]: string[] + } + }; +} + +/** + * Parameters for the {@link buildRangeParameter} function + * At least one of 'from' or 'to' is required. + */ +export interface BuildRangeParameterParams { + from?: T; + to?: T; + beginInclusive?: boolean; + endInclusive?: boolean; +} + +/** + * Helper function to build search ranges for priority or date values. + * @param params Description of the range. + * + * ```typescript + * import { buildRangeParameter } from "@dvelop-sdk/task"; + * + * const range = buildRangeParameter({ + * from: new Date(), + * to: new Date("2025-01-01T00:00:00.000Z"), + * beginInclusive: true, + * endInclusive: true + * }); + * ``` + */ +export function buildRangeParameter(params: BuildRangeParameterParams) : string { + let result = ""; + + if (params.from === undefined && params.to === undefined) { + throw new Error("A range must define at least on value from 'from' or 'to'"); + } + + if (params.beginInclusive ?? true) { + result += "["; + } else { + result += "("; + } + + if (params.from !== undefined) { + if (params.from instanceof Date) { + result += params.from.toISOString(); + } else { + result += params.from; + } + } + + result += ".."; + + if (params.to !== undefined) { + if (params.to instanceof Date) { + result += params.to.toISOString(); + } else { + result += params.to; + } + } + + if (params.endInclusive ?? true) { + result += "]"; + } else { + result += ")"; + } + + return result; +} + +/** + * A result page returned by the {@link searchTasks} function. + */ +export interface SearchTasksPage { + /** Tasks that match the search parameters */ + tasks: Task[], + /** Gets the next page for this search. Undefined if there is none */ + getNextPage? : () => Promise +} + +/** + * Factory for the default transform-function provided to the {@link searchTasks}-function. See [Advanced Topics](https://github.com/d-velop/dvelop-sdk-node#advanced-topics) for more information. + * @internal + * @category Task + */ +export function _searchTasksDefaultTransformFunctionFactory(httpRequestFunction: (context: DvelopContext, config: HttpConfig) => Promise): (response: HttpResponse, _: DvelopContext, __: SearchTasksParams) => SearchTasksPage { + return (response: HttpResponse, context: DvelopContext, params: SearchTasksParams) => { + let page : SearchTasksPage = { + tasks: response.data.tasks + }; + + page.tasks.forEach(task => { + if (task.receiveDate) { + task.receiveDate = new Date(task.receiveDate); + } + if (task.reminderDate) { + task.reminderDate = new Date(task.reminderDate); + } + if (task.dueDate) { + task.dueDate = new Date(task.dueDate); + } + if (task.completionDate) { + task.completionDate = new Date(task.completionDate); + } + }); + + if (response.data._links?.next) { + page.getNextPage = async () => { + const nextResponse: HttpResponse = await httpRequestFunction(context, { + method: "POST", + url: response.data._links.next.href, + data: params + }); + return _searchTasksDefaultTransformFunctionFactory(httpRequestFunction)(nextResponse, context, params); + }; + } + + return page; + }; +} + +/** + * Factory for the {@link searchTasks}-function. See [Advanced Topics](https://github.com/d-velop/dvelop-sdk-node#advanced-topics) for more information. + * @typeparam T Return type of the {@link searchTasks}-function. A corresponding transformFunction has to be supplied. + * @internal + * @category Task + */ +export function _searchTasksFactory( + httpRequestFunction: (context: DvelopContext, config: HttpConfig) => Promise, + transformFunction: (response: HttpResponse, context: DvelopContext, params: SearchTasksParams) => T +): (context: DvelopContext, params: SearchTasksParams) => Promise { + + return async (context: DvelopContext, params: SearchTasksParams) => { + const response: HttpResponse = await httpRequestFunction(context, { + method: "POST", + url: "/task/api/tasks/search", + data: params + }); + + return transformFunction(response, context, params); + }; +} + +/** + * Search for tasks. + * @returns A page of matching tasks. + * + * ```typescript + * import { searchTasks } from "@dvelop-sdk/task"; + * + * const task = await searchTasks({ + * systemBaseUri: "https://umbrella-corp.d-velop.cloud", + * authSessionId: "dQw4w9WgXcQ" + * }, { + * pageSize: 10, + * filter: { + * subject: ["My subject"] + * } + * }); + * ``` + * + * @category Task + */ +/* istanbul ignore next */ +export function searchTasks(context: DvelopContext, params: SearchTasksParams): Promise { + return _searchTasksFactory(_defaultHttpRequestFunction, _searchTasksDefaultTransformFunctionFactory(_defaultHttpRequestFunction))(context, params); +} \ No newline at end of file diff --git a/packages/task/src/utils/http.ts b/packages/task/src/utils/http.ts index 00028fd2..fad965d7 100644 --- a/packages/task/src/utils/http.ts +++ b/packages/task/src/utils/http.ts @@ -83,7 +83,8 @@ export function _defaultHttpRequestFunctionFactory(httpClient: DvelopHttpClient) case 404: throw new NotFoundError("Task-App responded with Status 404 indicating a requested resource does not exist. See 'originalError'-property for details.", error); - + case 429: + throw new TaskError("Task-App responded with status 429 indicating that you sent too many requests in a short time. Consider throttling your requests.", error); default: throw new TaskError(`Task-App responded with status ${error.response.status}. See 'originalError'-property for details.`, error); }