diff --git a/src/TodoistApi.activities.test.ts b/src/TodoistApi.activities.test.ts new file mode 100644 index 0000000..acaad5e --- /dev/null +++ b/src/TodoistApi.activities.test.ts @@ -0,0 +1,165 @@ +import { TodoistApi, type ActivityEvent } from '.' +import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults' +import { getSyncBaseUri, ENDPOINT_REST_ACTIVITIES } from './consts/endpoints' +import { setupRestClientMock } from './testUtils/mocks' + +function getTarget(baseUrl = 'https://api.todoist.com') { + return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl) +} + +const DEFAULT_ACTIVITY_RESPONSE: ActivityEvent[] = [ + { + id: '1', + objectType: 'item', + objectId: '123456', + eventType: 'added', + eventDate: '2025-01-10T10:00:00Z', + parentProjectId: '789', + parentItemId: null, + initiatorId: 'user123', + extraData: { + content: 'Test task', + client: 'web', + }, + }, + { + id: '2', + objectType: 'project', + objectId: '789', + eventType: 'updated', + eventDate: '2025-01-10T11:00:00Z', + parentProjectId: null, + parentItemId: null, + initiatorId: 'user123', + extraData: { + name: 'Updated Project', + last_name: 'Old Project', + }, + }, +] + +const ACTIVITY_WITH_UNKNOWN_FIELDS: ActivityEvent[] = [ + { + id: '3', + objectType: 'future_type', + objectId: '999', + eventType: 'new_event_type', + eventDate: '2025-01-10T12:00:00Z', + parentProjectId: null, + parentItemId: null, + initiatorId: null, + extraData: { + future_field: 'some value', + another_unknown: 123, + }, + unknownField1: 'should not crash', + unknownField2: { nested: 'object' }, + } as ActivityEvent, +] + +describe('TodoistApi activity endpoints', () => { + describe('getActivityLogs', () => { + test('calls get on restClient with expected parameters', async () => { + const requestMock = setupRestClientMock({ + results: DEFAULT_ACTIVITY_RESPONSE, + nextCursor: null, + }) + const api = getTarget() + + await api.getActivityLogs() + + expect(requestMock).toHaveBeenCalledTimes(1) + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(), + ENDPOINT_REST_ACTIVITIES, + DEFAULT_AUTH_TOKEN, + {}, + ) + }) + + test('returns activity events from response', async () => { + setupRestClientMock({ + results: DEFAULT_ACTIVITY_RESPONSE, + nextCursor: null, + }) + const api = getTarget() + + const result = await api.getActivityLogs() + + expect(result.results).toHaveLength(2) + expect(result.results[0].objectType).toBe('item') + expect(result.results[0].eventType).toBe('added') + expect(result.nextCursor).toBeNull() + }) + + test('handles pagination with cursor and limit', async () => { + const requestMock = setupRestClientMock({ + results: DEFAULT_ACTIVITY_RESPONSE, + nextCursor: 'next_cursor_token', + }) + const api = getTarget() + + const result = await api.getActivityLogs({ + cursor: 'prev_cursor', + limit: 10, + }) + + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(), + ENDPOINT_REST_ACTIVITIES, + DEFAULT_AUTH_TOKEN, + { + cursor: 'prev_cursor', + limit: 10, + }, + ) + expect(result.nextCursor).toBe('next_cursor_token') + }) + + test('handles filter parameters', async () => { + const requestMock = setupRestClientMock({ + results: DEFAULT_ACTIVITY_RESPONSE, + nextCursor: null, + }) + const api = getTarget() + + await api.getActivityLogs({ + objectType: 'item', + eventType: 'completed', + parentProjectId: '789', + }) + + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(), + ENDPOINT_REST_ACTIVITIES, + DEFAULT_AUTH_TOKEN, + { + objectType: 'item', + eventType: 'completed', + parentProjectId: '789', + }, + ) + }) + + test('handles unknown event types and fields without crashing', async () => { + setupRestClientMock({ + results: ACTIVITY_WITH_UNKNOWN_FIELDS, + nextCursor: null, + }) + const api = getTarget() + + const result = await api.getActivityLogs() + + expect(result.results).toHaveLength(1) + expect(result.results[0].objectType).toBe('future_type') + expect(result.results[0].eventType).toBe('new_event_type') + expect(result.results[0].extraData).toEqual({ + future_field: 'some value', + another_unknown: 123, + }) + }) + }) +}) diff --git a/src/TodoistApi.ts b/src/TodoistApi.ts index 7710207..8f24363 100644 --- a/src/TodoistApi.ts +++ b/src/TodoistApi.ts @@ -45,6 +45,8 @@ import { GetArchivedProjectsArgs, GetArchivedProjectsResponse, SearchCompletedTasksArgs, + GetActivityLogsArgs, + GetActivityLogsResponse, } from './types/requests' import { request, isSuccess } from './restClient' import { @@ -71,6 +73,7 @@ import { ENDPOINT_REST_PROJECTS_ARCHIVED, ENDPOINT_REST_USER, ENDPOINT_REST_PRODUCTIVITY, + ENDPOINT_REST_ACTIVITIES, } from './consts/endpoints' import { validateComment, @@ -86,6 +89,7 @@ import { validateTaskArray, validateUserArray, validateProductivityStats, + validateActivityEventArray, } from './utils/validators' import { z } from 'zod' @@ -1060,4 +1064,27 @@ export class TodoistApi { ) return validateProductivityStats(response.data) } + + /** + * Retrieves activity logs with optional filters. + * + * @param args - Optional filter parameters for activity logs. + * @returns A promise that resolves to a paginated response of activity events. + */ + async getActivityLogs(args: GetActivityLogsArgs = {}): Promise { + const { + data: { results, nextCursor }, + } = await request( + 'GET', + this.syncApiBase, + ENDPOINT_REST_ACTIVITIES, + this.authToken, + args, + ) + + return { + results: validateActivityEventArray(results), + nextCursor, + } + } } diff --git a/src/consts/endpoints.ts b/src/consts/endpoints.ts index d60e09d..a3ef043 100644 --- a/src/consts/endpoints.ts +++ b/src/consts/endpoints.ts @@ -37,6 +37,7 @@ export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archiv export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators' export const ENDPOINT_REST_USER = 'user' export const ENDPOINT_REST_PRODUCTIVITY = ENDPOINT_REST_TASKS + '/completed/stats' +export const ENDPOINT_REST_ACTIVITIES = 'activities' export const PROJECT_ARCHIVE = 'archive' export const PROJECT_UNARCHIVE = 'unarchive' diff --git a/src/types/entities.ts b/src/types/entities.ts index 2171bd9..fe2b185 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -401,3 +401,52 @@ export const ColorSchema = z.object({ * @see https://todoist.com/api/v1/docs#tag/Colors */ export type Color = z.infer + +/** + * Type hints for known object types. Accepts any string for forward compatibility. + */ +export type ActivityObjectType = 'item' | 'note' | 'project' | (string & Record) + +/** + * Type hints for known event types. Accepts any string for forward compatibility. + */ +export type ActivityEventType = + | 'added' + | 'updated' + | 'deleted' + | 'completed' + | 'uncompleted' + | 'archived' + | 'unarchived' + | 'shared' + | 'left' + | (string & Record) + +/** + * Flexible object containing event-specific data. + * Uses z.record to accept any properties for forward compatibility. + */ +export const ActivityEventExtraDataSchema = z.record(z.string(), z.any()).nullable() +export type ActivityEventExtraData = z.infer + +/** + * Activity log event schema. Accepts unknown fields for forward compatibility. + */ +export const ActivityEventSchema = z + .object({ + objectType: z.string(), + objectId: z.string(), + eventType: z.string(), + eventDate: z.string(), + id: z.string().nullable(), + parentProjectId: z.string().nullable(), + parentItemId: z.string().nullable(), + initiatorId: z.string().nullable(), + extraData: ActivityEventExtraDataSchema, + }) + .catchall(z.any()) + +/** + * Represents an activity log event in Todoist. + */ +export type ActivityEvent = z.infer diff --git a/src/types/requests.ts b/src/types/requests.ts index f587603..dfc0573 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -1,5 +1,6 @@ import type { RequireAllOrNone, RequireOneOrNone, RequireExactlyOne } from 'type-fest' import type { + ActivityEvent, Comment, Duration, Label, @@ -430,3 +431,33 @@ export type AddCommentArgs = { export type UpdateCommentArgs = { content: string } + +/** + * Arguments for retrieving activity logs. + */ +export type GetActivityLogsArgs = { + objectType?: string + eventType?: string + objectId?: string + parentProjectId?: string + parentItemId?: string + includeParentObject?: boolean + includeChildObjects?: boolean + initiatorId?: string + initiatorIdNull?: boolean | null + ensureLastState?: boolean + annotateNotes?: boolean + annotateParents?: boolean + since?: string + until?: string + cursor?: string | null + limit?: number +} + +/** + * Response from retrieving activity logs. + */ +export type GetActivityLogsResponse = { + results: ActivityEvent[] + nextCursor: string | null +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 7b31b0d..a527acd 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -17,6 +17,8 @@ import { type WorkspaceProject, type PersonalProject, ProductivityStatsSchema, + ActivityEventSchema, + type ActivityEvent, } from '../types/entities' export function validateTask(input: unknown): Task { @@ -104,3 +106,11 @@ export function validateProductivityStats(input: unknown): ProductivityStats { export function validateCurrentUser(input: unknown): CurrentUser { return CurrentUserSchema.parse(input) } + +export function validateActivityEvent(input: unknown): ActivityEvent { + return ActivityEventSchema.parse(input) +} + +export function validateActivityEventArray(input: unknown[]): ActivityEvent[] { + return input.map(validateActivityEvent) +}