Skip to content

Commit f52ab84

Browse files
authored
feat: Adds support for the Todoist activity logs API endpoint (#351)
1 parent 0f2acb1 commit f52ab84

File tree

6 files changed

+283
-0
lines changed

6 files changed

+283
-0
lines changed

src/TodoistApi.activities.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { TodoistApi, type ActivityEvent } from '.'
2+
import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults'
3+
import { getSyncBaseUri, ENDPOINT_REST_ACTIVITIES } from './consts/endpoints'
4+
import { setupRestClientMock } from './testUtils/mocks'
5+
6+
function getTarget(baseUrl = 'https://api.todoist.com') {
7+
return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl)
8+
}
9+
10+
const DEFAULT_ACTIVITY_RESPONSE: ActivityEvent[] = [
11+
{
12+
id: '1',
13+
objectType: 'item',
14+
objectId: '123456',
15+
eventType: 'added',
16+
eventDate: '2025-01-10T10:00:00Z',
17+
parentProjectId: '789',
18+
parentItemId: null,
19+
initiatorId: 'user123',
20+
extraData: {
21+
content: 'Test task',
22+
client: 'web',
23+
},
24+
},
25+
{
26+
id: '2',
27+
objectType: 'project',
28+
objectId: '789',
29+
eventType: 'updated',
30+
eventDate: '2025-01-10T11:00:00Z',
31+
parentProjectId: null,
32+
parentItemId: null,
33+
initiatorId: 'user123',
34+
extraData: {
35+
name: 'Updated Project',
36+
last_name: 'Old Project',
37+
},
38+
},
39+
]
40+
41+
const ACTIVITY_WITH_UNKNOWN_FIELDS: ActivityEvent[] = [
42+
{
43+
id: '3',
44+
objectType: 'future_type',
45+
objectId: '999',
46+
eventType: 'new_event_type',
47+
eventDate: '2025-01-10T12:00:00Z',
48+
parentProjectId: null,
49+
parentItemId: null,
50+
initiatorId: null,
51+
extraData: {
52+
future_field: 'some value',
53+
another_unknown: 123,
54+
},
55+
unknownField1: 'should not crash',
56+
unknownField2: { nested: 'object' },
57+
} as ActivityEvent,
58+
]
59+
60+
describe('TodoistApi activity endpoints', () => {
61+
describe('getActivityLogs', () => {
62+
test('calls get on restClient with expected parameters', async () => {
63+
const requestMock = setupRestClientMock({
64+
results: DEFAULT_ACTIVITY_RESPONSE,
65+
nextCursor: null,
66+
})
67+
const api = getTarget()
68+
69+
await api.getActivityLogs()
70+
71+
expect(requestMock).toHaveBeenCalledTimes(1)
72+
expect(requestMock).toHaveBeenCalledWith(
73+
'GET',
74+
getSyncBaseUri(),
75+
ENDPOINT_REST_ACTIVITIES,
76+
DEFAULT_AUTH_TOKEN,
77+
{},
78+
)
79+
})
80+
81+
test('returns activity events from response', async () => {
82+
setupRestClientMock({
83+
results: DEFAULT_ACTIVITY_RESPONSE,
84+
nextCursor: null,
85+
})
86+
const api = getTarget()
87+
88+
const result = await api.getActivityLogs()
89+
90+
expect(result.results).toHaveLength(2)
91+
expect(result.results[0].objectType).toBe('item')
92+
expect(result.results[0].eventType).toBe('added')
93+
expect(result.nextCursor).toBeNull()
94+
})
95+
96+
test('handles pagination with cursor and limit', async () => {
97+
const requestMock = setupRestClientMock({
98+
results: DEFAULT_ACTIVITY_RESPONSE,
99+
nextCursor: 'next_cursor_token',
100+
})
101+
const api = getTarget()
102+
103+
const result = await api.getActivityLogs({
104+
cursor: 'prev_cursor',
105+
limit: 10,
106+
})
107+
108+
expect(requestMock).toHaveBeenCalledWith(
109+
'GET',
110+
getSyncBaseUri(),
111+
ENDPOINT_REST_ACTIVITIES,
112+
DEFAULT_AUTH_TOKEN,
113+
{
114+
cursor: 'prev_cursor',
115+
limit: 10,
116+
},
117+
)
118+
expect(result.nextCursor).toBe('next_cursor_token')
119+
})
120+
121+
test('handles filter parameters', async () => {
122+
const requestMock = setupRestClientMock({
123+
results: DEFAULT_ACTIVITY_RESPONSE,
124+
nextCursor: null,
125+
})
126+
const api = getTarget()
127+
128+
await api.getActivityLogs({
129+
objectType: 'item',
130+
eventType: 'completed',
131+
parentProjectId: '789',
132+
})
133+
134+
expect(requestMock).toHaveBeenCalledWith(
135+
'GET',
136+
getSyncBaseUri(),
137+
ENDPOINT_REST_ACTIVITIES,
138+
DEFAULT_AUTH_TOKEN,
139+
{
140+
objectType: 'item',
141+
eventType: 'completed',
142+
parentProjectId: '789',
143+
},
144+
)
145+
})
146+
147+
test('handles unknown event types and fields without crashing', async () => {
148+
setupRestClientMock({
149+
results: ACTIVITY_WITH_UNKNOWN_FIELDS,
150+
nextCursor: null,
151+
})
152+
const api = getTarget()
153+
154+
const result = await api.getActivityLogs()
155+
156+
expect(result.results).toHaveLength(1)
157+
expect(result.results[0].objectType).toBe('future_type')
158+
expect(result.results[0].eventType).toBe('new_event_type')
159+
expect(result.results[0].extraData).toEqual({
160+
future_field: 'some value',
161+
another_unknown: 123,
162+
})
163+
})
164+
})
165+
})

src/TodoistApi.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
GetArchivedProjectsArgs,
4646
GetArchivedProjectsResponse,
4747
SearchCompletedTasksArgs,
48+
GetActivityLogsArgs,
49+
GetActivityLogsResponse,
4850
} from './types/requests'
4951
import { request, isSuccess } from './restClient'
5052
import {
@@ -71,6 +73,7 @@ import {
7173
ENDPOINT_REST_PROJECTS_ARCHIVED,
7274
ENDPOINT_REST_USER,
7375
ENDPOINT_REST_PRODUCTIVITY,
76+
ENDPOINT_REST_ACTIVITIES,
7477
} from './consts/endpoints'
7578
import {
7679
validateComment,
@@ -86,6 +89,7 @@ import {
8689
validateTaskArray,
8790
validateUserArray,
8891
validateProductivityStats,
92+
validateActivityEventArray,
8993
} from './utils/validators'
9094
import { z } from 'zod'
9195

@@ -1060,4 +1064,27 @@ export class TodoistApi {
10601064
)
10611065
return validateProductivityStats(response.data)
10621066
}
1067+
1068+
/**
1069+
* Retrieves activity logs with optional filters.
1070+
*
1071+
* @param args - Optional filter parameters for activity logs.
1072+
* @returns A promise that resolves to a paginated response of activity events.
1073+
*/
1074+
async getActivityLogs(args: GetActivityLogsArgs = {}): Promise<GetActivityLogsResponse> {
1075+
const {
1076+
data: { results, nextCursor },
1077+
} = await request<GetActivityLogsResponse>(
1078+
'GET',
1079+
this.syncApiBase,
1080+
ENDPOINT_REST_ACTIVITIES,
1081+
this.authToken,
1082+
args,
1083+
)
1084+
1085+
return {
1086+
results: validateActivityEventArray(results),
1087+
nextCursor,
1088+
}
1089+
}
10631090
}

src/consts/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archiv
3737
export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators'
3838
export const ENDPOINT_REST_USER = 'user'
3939
export const ENDPOINT_REST_PRODUCTIVITY = ENDPOINT_REST_TASKS + '/completed/stats'
40+
export const ENDPOINT_REST_ACTIVITIES = 'activities'
4041
export const PROJECT_ARCHIVE = 'archive'
4142
export const PROJECT_UNARCHIVE = 'unarchive'
4243

src/types/entities.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,52 @@ export const ColorSchema = z.object({
401401
* @see https://todoist.com/api/v1/docs#tag/Colors
402402
*/
403403
export type Color = z.infer<typeof ColorSchema>
404+
405+
/**
406+
* Type hints for known object types. Accepts any string for forward compatibility.
407+
*/
408+
export type ActivityObjectType = 'item' | 'note' | 'project' | (string & Record<string, never>)
409+
410+
/**
411+
* Type hints for known event types. Accepts any string for forward compatibility.
412+
*/
413+
export type ActivityEventType =
414+
| 'added'
415+
| 'updated'
416+
| 'deleted'
417+
| 'completed'
418+
| 'uncompleted'
419+
| 'archived'
420+
| 'unarchived'
421+
| 'shared'
422+
| 'left'
423+
| (string & Record<string, never>)
424+
425+
/**
426+
* Flexible object containing event-specific data.
427+
* Uses z.record to accept any properties for forward compatibility.
428+
*/
429+
export const ActivityEventExtraDataSchema = z.record(z.string(), z.any()).nullable()
430+
export type ActivityEventExtraData = z.infer<typeof ActivityEventExtraDataSchema>
431+
432+
/**
433+
* Activity log event schema. Accepts unknown fields for forward compatibility.
434+
*/
435+
export const ActivityEventSchema = z
436+
.object({
437+
objectType: z.string(),
438+
objectId: z.string(),
439+
eventType: z.string(),
440+
eventDate: z.string(),
441+
id: z.string().nullable(),
442+
parentProjectId: z.string().nullable(),
443+
parentItemId: z.string().nullable(),
444+
initiatorId: z.string().nullable(),
445+
extraData: ActivityEventExtraDataSchema,
446+
})
447+
.catchall(z.any())
448+
449+
/**
450+
* Represents an activity log event in Todoist.
451+
*/
452+
export type ActivityEvent = z.infer<typeof ActivityEventSchema>

src/types/requests.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RequireAllOrNone, RequireOneOrNone, RequireExactlyOne } from 'type-fest'
22
import type {
3+
ActivityEvent,
34
Comment,
45
Duration,
56
Label,
@@ -430,3 +431,33 @@ export type AddCommentArgs = {
430431
export type UpdateCommentArgs = {
431432
content: string
432433
}
434+
435+
/**
436+
* Arguments for retrieving activity logs.
437+
*/
438+
export type GetActivityLogsArgs = {
439+
objectType?: string
440+
eventType?: string
441+
objectId?: string
442+
parentProjectId?: string
443+
parentItemId?: string
444+
includeParentObject?: boolean
445+
includeChildObjects?: boolean
446+
initiatorId?: string
447+
initiatorIdNull?: boolean | null
448+
ensureLastState?: boolean
449+
annotateNotes?: boolean
450+
annotateParents?: boolean
451+
since?: string
452+
until?: string
453+
cursor?: string | null
454+
limit?: number
455+
}
456+
457+
/**
458+
* Response from retrieving activity logs.
459+
*/
460+
export type GetActivityLogsResponse = {
461+
results: ActivityEvent[]
462+
nextCursor: string | null
463+
}

src/utils/validators.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
type WorkspaceProject,
1818
type PersonalProject,
1919
ProductivityStatsSchema,
20+
ActivityEventSchema,
21+
type ActivityEvent,
2022
} from '../types/entities'
2123

2224
export function validateTask(input: unknown): Task {
@@ -104,3 +106,11 @@ export function validateProductivityStats(input: unknown): ProductivityStats {
104106
export function validateCurrentUser(input: unknown): CurrentUser {
105107
return CurrentUserSchema.parse(input)
106108
}
109+
110+
export function validateActivityEvent(input: unknown): ActivityEvent {
111+
return ActivityEventSchema.parse(input)
112+
}
113+
114+
export function validateActivityEventArray(input: unknown[]): ActivityEvent[] {
115+
return input.map(validateActivityEvent)
116+
}

0 commit comments

Comments
 (0)