Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/TodoistApi.activities.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
})
27 changes: 27 additions & 0 deletions src/TodoistApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
GetArchivedProjectsArgs,
GetArchivedProjectsResponse,
SearchCompletedTasksArgs,
GetActivityLogsArgs,
GetActivityLogsResponse,
} from './types/requests'
import { request, isSuccess } from './restClient'
import {
Expand All @@ -71,6 +73,7 @@ import {
ENDPOINT_REST_PROJECTS_ARCHIVED,
ENDPOINT_REST_USER,
ENDPOINT_REST_PRODUCTIVITY,
ENDPOINT_REST_ACTIVITIES,
} from './consts/endpoints'
import {
validateComment,
Expand All @@ -86,6 +89,7 @@ import {
validateTaskArray,
validateUserArray,
validateProductivityStats,
validateActivityEventArray,
} from './utils/validators'
import { z } from 'zod'

Expand Down Expand Up @@ -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<GetActivityLogsResponse> {
const {
data: { results, nextCursor },
} = await request<GetActivityLogsResponse>(
'GET',
this.syncApiBase,
ENDPOINT_REST_ACTIVITIES,
this.authToken,
args,
)

return {
results: validateActivityEventArray(results),
nextCursor,
}
}
}
1 change: 1 addition & 0 deletions src/consts/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
49 changes: 49 additions & 0 deletions src/types/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,52 @@ export const ColorSchema = z.object({
* @see https://todoist.com/api/v1/docs#tag/Colors
*/
export type Color = z.infer<typeof ColorSchema>

/**
* Type hints for known object types. Accepts any string for forward compatibility.
*/
export type ActivityObjectType = 'item' | 'note' | 'project' | (string & Record<string, never>)

/**
* 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<string, never>)

/**
* 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<typeof ActivityEventExtraDataSchema>

/**
* 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<typeof ActivityEventSchema>
31 changes: 31 additions & 0 deletions src/types/requests.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RequireAllOrNone, RequireOneOrNone, RequireExactlyOne } from 'type-fest'
import type {
ActivityEvent,
Comment,
Duration,
Label,
Expand Down Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions src/utils/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
type WorkspaceProject,
type PersonalProject,
ProductivityStatsSchema,
ActivityEventSchema,
type ActivityEvent,
} from '../types/entities'

export function validateTask(input: unknown): Task {
Expand Down Expand Up @@ -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)
}