@doist/comms-sdk 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -45
  3. package/dist/cjs/authentication.js +211 -0
  4. package/dist/cjs/clients/add-comment-helper.js +70 -0
  5. package/dist/cjs/clients/base-client.js +25 -0
  6. package/dist/cjs/clients/channels-client.js +200 -0
  7. package/dist/cjs/clients/comments-client.js +159 -0
  8. package/dist/cjs/clients/conversation-messages-client.js +158 -0
  9. package/dist/cjs/clients/conversations-client.js +243 -0
  10. package/dist/cjs/clients/groups-client.js +164 -0
  11. package/dist/cjs/clients/inbox-client.js +171 -0
  12. package/dist/cjs/clients/reactions-client.js +97 -0
  13. package/dist/cjs/clients/search-client.js +138 -0
  14. package/dist/cjs/clients/threads-client.js +330 -0
  15. package/dist/cjs/clients/users-client.js +326 -0
  16. package/dist/cjs/clients/workspace-users-client.js +240 -0
  17. package/dist/cjs/clients/workspaces-client.js +166 -0
  18. package/dist/cjs/comms-api.js +66 -0
  19. package/dist/cjs/consts/endpoints.js +32 -0
  20. package/dist/cjs/index.js +48 -0
  21. package/dist/cjs/package.json +1 -0
  22. package/dist/cjs/testUtils/msw-handlers.js +51 -0
  23. package/dist/cjs/testUtils/msw-setup.js +21 -0
  24. package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
  25. package/dist/cjs/testUtils/test-defaults.js +104 -0
  26. package/dist/cjs/transport/fetch-with-retry.js +136 -0
  27. package/dist/cjs/transport/http-client.js +56 -0
  28. package/dist/cjs/transport/http-dispatcher.js +143 -0
  29. package/dist/cjs/types/api-version.js +8 -0
  30. package/dist/cjs/types/entities.js +411 -0
  31. package/dist/cjs/types/enums.js +37 -0
  32. package/dist/cjs/types/errors.js +12 -0
  33. package/dist/cjs/types/http.js +4 -0
  34. package/dist/cjs/types/index.js +22 -0
  35. package/dist/cjs/types/requests.js +116 -0
  36. package/dist/cjs/utils/case-conversion.js +54 -0
  37. package/dist/cjs/utils/index.js +19 -0
  38. package/dist/cjs/utils/timestamp-conversion.js +49 -0
  39. package/dist/cjs/utils/url-helpers.js +131 -0
  40. package/dist/cjs/utils/uuidv7.js +174 -0
  41. package/dist/esm/authentication.js +203 -0
  42. package/dist/esm/clients/add-comment-helper.js +67 -0
  43. package/dist/esm/clients/base-client.js +21 -0
  44. package/dist/esm/clients/channels-client.js +196 -0
  45. package/dist/esm/clients/comments-client.js +155 -0
  46. package/dist/esm/clients/conversation-messages-client.js +154 -0
  47. package/dist/esm/clients/conversations-client.js +239 -0
  48. package/dist/esm/clients/groups-client.js +160 -0
  49. package/dist/esm/clients/inbox-client.js +167 -0
  50. package/dist/esm/clients/reactions-client.js +93 -0
  51. package/dist/esm/clients/search-client.js +134 -0
  52. package/dist/esm/clients/threads-client.js +326 -0
  53. package/dist/esm/clients/users-client.js +322 -0
  54. package/dist/esm/clients/workspace-users-client.js +236 -0
  55. package/dist/esm/clients/workspaces-client.js +162 -0
  56. package/dist/esm/comms-api.js +62 -0
  57. package/dist/esm/consts/endpoints.js +28 -0
  58. package/dist/esm/index.js +17 -0
  59. package/dist/esm/testUtils/msw-handlers.js +45 -0
  60. package/dist/esm/testUtils/msw-setup.js +18 -0
  61. package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
  62. package/dist/esm/testUtils/test-defaults.js +101 -0
  63. package/dist/esm/transport/fetch-with-retry.js +133 -0
  64. package/dist/esm/transport/http-client.js +51 -0
  65. package/dist/esm/transport/http-dispatcher.js +104 -0
  66. package/dist/esm/types/api-version.js +5 -0
  67. package/dist/esm/types/entities.js +408 -0
  68. package/dist/esm/types/enums.js +34 -0
  69. package/dist/esm/types/errors.js +8 -0
  70. package/dist/esm/types/http.js +1 -0
  71. package/dist/esm/types/index.js +6 -0
  72. package/dist/esm/types/requests.js +113 -0
  73. package/dist/esm/utils/case-conversion.js +47 -0
  74. package/dist/esm/utils/index.js +3 -0
  75. package/dist/esm/utils/timestamp-conversion.js +45 -0
  76. package/dist/esm/utils/url-helpers.js +112 -0
  77. package/dist/esm/utils/uuidv7.js +163 -0
  78. package/dist/types/authentication.d.ts +160 -0
  79. package/dist/types/clients/add-comment-helper.d.ts +29 -0
  80. package/dist/types/clients/base-client.d.ts +28 -0
  81. package/dist/types/clients/channels-client.d.ts +208 -0
  82. package/dist/types/clients/comments-client.d.ts +224 -0
  83. package/dist/types/clients/conversation-messages-client.d.ts +198 -0
  84. package/dist/types/clients/conversations-client.d.ts +346 -0
  85. package/dist/types/clients/groups-client.d.ts +148 -0
  86. package/dist/types/clients/inbox-client.d.ts +96 -0
  87. package/dist/types/clients/reactions-client.d.ts +57 -0
  88. package/dist/types/clients/search-client.d.ts +70 -0
  89. package/dist/types/clients/threads-client.d.ts +536 -0
  90. package/dist/types/clients/users-client.d.ts +250 -0
  91. package/dist/types/clients/workspace-users-client.d.ts +147 -0
  92. package/dist/types/clients/workspaces-client.d.ts +152 -0
  93. package/dist/types/comms-api.d.ts +62 -0
  94. package/dist/types/consts/endpoints.d.ts +24 -0
  95. package/dist/types/index.d.ts +18 -0
  96. package/dist/types/testUtils/msw-handlers.d.ts +28 -0
  97. package/dist/types/testUtils/msw-setup.d.ts +1 -0
  98. package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
  99. package/dist/types/testUtils/test-defaults.d.ts +17 -0
  100. package/dist/types/transport/fetch-with-retry.d.ts +4 -0
  101. package/dist/types/transport/http-client.d.ts +13 -0
  102. package/dist/types/transport/http-dispatcher.d.ts +10 -0
  103. package/dist/types/types/api-version.d.ts +6 -0
  104. package/dist/types/types/entities.d.ts +1288 -0
  105. package/dist/types/types/enums.d.ts +55 -0
  106. package/dist/types/types/errors.d.ts +6 -0
  107. package/dist/types/types/http.d.ts +54 -0
  108. package/dist/types/types/index.d.ts +6 -0
  109. package/dist/types/types/requests.d.ts +366 -0
  110. package/dist/types/utils/case-conversion.d.ts +8 -0
  111. package/dist/types/utils/index.d.ts +3 -0
  112. package/dist/types/utils/timestamp-conversion.d.ts +13 -0
  113. package/dist/types/utils/url-helpers.d.ts +88 -0
  114. package/dist/types/utils/uuidv7.d.ts +40 -0
  115. package/package.json +91 -8
@@ -0,0 +1,162 @@
1
+ import { z } from 'zod';
2
+ import { ENDPOINT_WORKSPACES } from '../consts/endpoints.js';
3
+ import { request } from '../transport/http-client.js';
4
+ import { ChannelSchema, WorkspaceSchema } from '../types/entities.js';
5
+ import { BaseClient } from './base-client.js';
6
+ export const ChannelListSchema = z.array(ChannelSchema);
7
+ /**
8
+ * Client for `/api/v1/workspaces/`. Workspace IDs are integers. The backend
9
+ * currently rejects any `color` other than `1` on add/update.
10
+ */
11
+ export class WorkspacesClient extends BaseClient {
12
+ /**
13
+ * Gets all the user's workspaces.
14
+ *
15
+ * @returns An array of all workspaces the user belongs to.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const workspaces = await api.workspaces.getWorkspaces()
20
+ * workspaces.forEach(ws => console.log(ws.name))
21
+ * ```
22
+ */
23
+ getWorkspaces() {
24
+ return request({
25
+ httpMethod: 'GET',
26
+ baseUri: this.getBaseUri(),
27
+ relativePath: `${ENDPOINT_WORKSPACES}/get`,
28
+ apiToken: this.apiToken,
29
+ payload: undefined,
30
+ customFetch: this.customFetch,
31
+ }).then((response) => response.data.map((workspace) => WorkspaceSchema.parse(workspace)));
32
+ }
33
+ /**
34
+ * Gets a single workspace object by id.
35
+ *
36
+ * @param id - The workspace ID.
37
+ * @returns The workspace object.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * const workspace = await api.workspaces.getWorkspace(123)
42
+ * console.log(workspace.name)
43
+ * ```
44
+ */
45
+ getWorkspace(id) {
46
+ return request({
47
+ httpMethod: 'GET',
48
+ baseUri: this.getBaseUri(),
49
+ relativePath: `${ENDPOINT_WORKSPACES}/getone`,
50
+ apiToken: this.apiToken,
51
+ payload: { id },
52
+ customFetch: this.customFetch,
53
+ }).then((response) => WorkspaceSchema.parse(response.data));
54
+ }
55
+ /**
56
+ * Gets the user's default workspace.
57
+ *
58
+ * @returns The default workspace object.
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const workspace = await api.workspaces.getDefaultWorkspace()
63
+ * console.log(workspace.name)
64
+ * ```
65
+ */
66
+ getDefaultWorkspace() {
67
+ return request({
68
+ httpMethod: 'GET',
69
+ baseUri: this.getBaseUri(),
70
+ relativePath: `${ENDPOINT_WORKSPACES}/get_default`,
71
+ apiToken: this.apiToken,
72
+ payload: undefined,
73
+ customFetch: this.customFetch,
74
+ }).then((response) => WorkspaceSchema.parse(response.data));
75
+ }
76
+ /**
77
+ * Creates a new workspace.
78
+ *
79
+ * @param name - The name of the new workspace.
80
+ * @returns The created workspace object.
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * const workspace = await api.workspaces.createWorkspace('My Team')
85
+ * console.log('Created:', workspace.name)
86
+ * ```
87
+ */
88
+ createWorkspace(name) {
89
+ return request({
90
+ httpMethod: 'POST',
91
+ baseUri: this.getBaseUri(),
92
+ relativePath: `${ENDPOINT_WORKSPACES}/add`,
93
+ apiToken: this.apiToken,
94
+ payload: { name },
95
+ customFetch: this.customFetch,
96
+ }).then((response) => WorkspaceSchema.parse(response.data));
97
+ }
98
+ /**
99
+ * Updates an existing workspace.
100
+ *
101
+ * @param id - The workspace ID.
102
+ * @param name - The new name for the workspace.
103
+ * @returns The updated workspace object.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * const workspace = await api.workspaces.updateWorkspace(123, 'New Team Name')
108
+ * ```
109
+ */
110
+ updateWorkspace(id, name) {
111
+ return request({
112
+ httpMethod: 'POST',
113
+ baseUri: this.getBaseUri(),
114
+ relativePath: `${ENDPOINT_WORKSPACES}/update`,
115
+ apiToken: this.apiToken,
116
+ payload: { id, name },
117
+ customFetch: this.customFetch,
118
+ }).then((response) => WorkspaceSchema.parse(response.data));
119
+ }
120
+ /**
121
+ * Removes a workspace and all its data (not recoverable).
122
+ *
123
+ * @param id - The workspace ID.
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * await api.workspaces.removeWorkspace(123)
128
+ * ```
129
+ */
130
+ removeWorkspace(id) {
131
+ return request({
132
+ httpMethod: 'POST',
133
+ baseUri: this.getBaseUri(),
134
+ relativePath: `${ENDPOINT_WORKSPACES}/remove`,
135
+ apiToken: this.apiToken,
136
+ payload: { id },
137
+ customFetch: this.customFetch,
138
+ }).then(() => undefined);
139
+ }
140
+ /**
141
+ * Gets the public channels of a workspace.
142
+ *
143
+ * @param id - The workspace ID.
144
+ * @returns An array of public channel objects.
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const channels = await api.workspaces.getPublicChannels(123)
149
+ * channels.forEach(ch => console.log(ch.name))
150
+ * ```
151
+ */
152
+ getPublicChannels(id) {
153
+ return request({
154
+ httpMethod: 'GET',
155
+ baseUri: this.getBaseUri(),
156
+ relativePath: `${ENDPOINT_WORKSPACES}/get_public_channels`,
157
+ apiToken: this.apiToken,
158
+ payload: { id },
159
+ customFetch: this.customFetch,
160
+ }).then((response) => ChannelListSchema.parse(response.data));
161
+ }
162
+ }
@@ -0,0 +1,62 @@
1
+ import { ChannelsClient } from './clients/channels-client.js';
2
+ import { CommentsClient } from './clients/comments-client.js';
3
+ import { ConversationMessagesClient } from './clients/conversation-messages-client.js';
4
+ import { ConversationsClient } from './clients/conversations-client.js';
5
+ import { GroupsClient } from './clients/groups-client.js';
6
+ import { InboxClient } from './clients/inbox-client.js';
7
+ import { ReactionsClient } from './clients/reactions-client.js';
8
+ import { SearchClient } from './clients/search-client.js';
9
+ import { ThreadsClient } from './clients/threads-client.js';
10
+ import { UsersClient } from './clients/users-client.js';
11
+ import { WorkspaceUsersClient } from './clients/workspace-users-client.js';
12
+ import { WorkspacesClient } from './clients/workspaces-client.js';
13
+ import { closeDefaultDispatcher } from './transport/http-dispatcher.js';
14
+ /**
15
+ * The main API client for interacting with the Comms REST API.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { CommsApi } from '@doist/comms-sdk'
20
+ *
21
+ * const api = new CommsApi('your-api-token')
22
+ * const user = await api.users.getSessionUser()
23
+ * ```
24
+ */
25
+ export class CommsApi {
26
+ /**
27
+ * Creates a new Comms API client.
28
+ *
29
+ * @param authToken - Your Comms API token.
30
+ * @param options - Optional configuration options.
31
+ */
32
+ constructor(authToken, options) {
33
+ const clientConfig = {
34
+ apiToken: authToken,
35
+ baseUrl: options?.baseUrl,
36
+ version: options?.version,
37
+ customFetch: options?.customFetch,
38
+ };
39
+ this.users = new UsersClient(clientConfig);
40
+ this.workspaces = new WorkspacesClient(clientConfig);
41
+ this.workspaceUsers = new WorkspaceUsersClient(clientConfig);
42
+ this.channels = new ChannelsClient(clientConfig);
43
+ this.threads = new ThreadsClient(clientConfig);
44
+ this.groups = new GroupsClient(clientConfig);
45
+ this.conversations = new ConversationsClient(clientConfig);
46
+ this.comments = new CommentsClient(clientConfig);
47
+ this.conversationMessages = new ConversationMessagesClient(clientConfig);
48
+ this.inbox = new InboxClient(clientConfig);
49
+ this.reactions = new ReactionsClient(clientConfig);
50
+ this.search = new SearchClient(clientConfig);
51
+ }
52
+ /**
53
+ * Drains the SDK's process-global connection pool. CLIs and scripts
54
+ * should `await api.close()` before exit so Node's event loop empties
55
+ * immediately instead of waiting ~4s on keep-alive. Affects every
56
+ * `CommsApi` and OAuth helper in the same process — it's a
57
+ * process-shutdown gesture, not an instance teardown. Browser-safe.
58
+ */
59
+ async close() {
60
+ await closeDefaultDispatcher();
61
+ }
62
+ }
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_API_VERSION } from '../types/api-version.js';
2
+ const BASE_URI = 'https://comms.todoist.com';
3
+ /**
4
+ * Gets the base URI for Comms API requests.
5
+ *
6
+ * Preserves any path component on `domainBase` so callers can route through
7
+ * a proxy (e.g. `https://proxy.example.com/comms` → `.../comms/api/v1/`).
8
+ *
9
+ * @param version - API version. Defaults to 'v1'.
10
+ * @param domainBase - Custom domain base URL. Defaults to Comms' API domain.
11
+ * @returns Complete base URI with trailing slash (e.g., 'https://comms.todoist.com/api/v1/')
12
+ */
13
+ export function getCommsBaseUri(version = DEFAULT_API_VERSION, domainBase = BASE_URI) {
14
+ const base = domainBase.endsWith('/') ? domainBase : `${domainBase}/`;
15
+ return new URL(`api/${version}/`, base).toString();
16
+ }
17
+ export const ENDPOINT_USERS = 'users';
18
+ export const ENDPOINT_WORKSPACES = 'workspaces';
19
+ export const ENDPOINT_CHANNELS = 'channels';
20
+ export const ENDPOINT_THREADS = 'threads';
21
+ export const ENDPOINT_GROUPS = 'groups';
22
+ export const ENDPOINT_CONVERSATIONS = 'conversations';
23
+ export const ENDPOINT_COMMENTS = 'comments';
24
+ export const ENDPOINT_NOTIFICATIONS = 'notifications';
25
+ export const ENDPOINT_INBOX = 'inbox';
26
+ export const ENDPOINT_REACTIONS = 'reactions';
27
+ export const ENDPOINT_SEARCH = 'search';
28
+ export const ENDPOINT_CONVERSATION_MESSAGES = 'conversation_messages';
@@ -0,0 +1,17 @@
1
+ export * from './authentication.js';
2
+ export { ChannelsClient } from './clients/channels-client.js';
3
+ export { CommentsClient } from './clients/comments-client.js';
4
+ export { ConversationMessagesClient } from './clients/conversation-messages-client.js';
5
+ export { ConversationsClient } from './clients/conversations-client.js';
6
+ export { GroupsClient } from './clients/groups-client.js';
7
+ export { InboxClient } from './clients/inbox-client.js';
8
+ export { ReactionsClient } from './clients/reactions-client.js';
9
+ export { SearchClient } from './clients/search-client.js';
10
+ export { ThreadsClient } from './clients/threads-client.js';
11
+ export { UsersClient } from './clients/users-client.js';
12
+ export { WorkspaceUsersClient } from './clients/workspace-users-client.js';
13
+ export { WorkspacesClient } from './clients/workspaces-client.js';
14
+ export { CommsApi } from './comms-api.js';
15
+ export { closeDefaultDispatcher } from './transport/http-dispatcher.js';
16
+ export * from './types/index.js';
17
+ export * from './utils/index.js';
@@ -0,0 +1,45 @@
1
+ import { HttpResponse, http } from 'msw';
2
+ const BASE_URL = 'https://comms.todoist.com';
3
+ /**
4
+ * Creates a successful API response with the given data
5
+ */
6
+ export function createSuccessResponse(data) {
7
+ return HttpResponse.json(data, { status: 200 });
8
+ }
9
+ /**
10
+ * Creates an error API response
11
+ */
12
+ export function createErrorResponse(errorCode, errorMessage, status = 400) {
13
+ return HttpResponse.json({
14
+ error_code: errorCode,
15
+ error_string: errorMessage,
16
+ }, { status });
17
+ }
18
+ /**
19
+ * Helper to create API endpoint URL
20
+ */
21
+ export function apiUrl(path) {
22
+ // Remove leading slash if present
23
+ const cleanPath = path.startsWith('/') ? path.slice(1) : path;
24
+ return `${BASE_URL}/${cleanPath}`;
25
+ }
26
+ /**
27
+ * Creates a handler for a GET endpoint
28
+ */
29
+ export function createGetHandler(endpoint, responseData) {
30
+ return http.get(apiUrl(endpoint), () => {
31
+ return createSuccessResponse(responseData);
32
+ });
33
+ }
34
+ /**
35
+ * Creates a handler for a POST endpoint
36
+ */
37
+ export function createPostHandler(endpoint, responseData) {
38
+ return http.post(apiUrl(endpoint), () => {
39
+ return createSuccessResponse(responseData);
40
+ });
41
+ }
42
+ /**
43
+ * Export http from msw for custom handlers
44
+ */
45
+ export { http, HttpResponse };
@@ -0,0 +1,18 @@
1
+ import { setupServer } from 'msw/node';
2
+ import { afterAll, afterEach, beforeAll } from 'vitest';
3
+ // Create MSW server instance
4
+ export const server = setupServer();
5
+ // Start server before all tests
6
+ beforeAll(() => {
7
+ // Only warn on unhandled requests instead of error, since transport/http-client.test.ts
8
+ // uses direct fetch mocking which bypasses MSW
9
+ server.listen({ onUnhandledRequest: 'warn' });
10
+ });
11
+ // Reset handlers after each test
12
+ afterEach(() => {
13
+ server.resetHandlers();
14
+ });
15
+ // Clean up after all tests
16
+ afterAll(() => {
17
+ server.close();
18
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Creates a CustomFetch adapter for Obsidian's requestUrl API.
3
+ *
4
+ * This adapter bridges the gap between Obsidian's requestUrl interface and the
5
+ * standard fetch-like interface expected by the Comms API SDK.
6
+ *
7
+ * Key differences handled by this adapter:
8
+ * - Obsidian returns response data as properties (response.json, response.text)
9
+ * while the SDK expects methods (response.json(), response.text())
10
+ * - Obsidian's requestUrl bypasses CORS restrictions that would block standard fetch
11
+ * - Obsidian throws on HTTP errors by default; we set throw: false to handle manually
12
+ * - Obsidian doesn't provide statusText; we default to empty string
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { requestUrl } from 'obsidian'
17
+ * import { createObsidianFetchAdapter } from './obsidian-fetch-adapter.js'
18
+ *
19
+ * const api = new CommsApi('your-token', {
20
+ * customFetch: createObsidianFetchAdapter(requestUrl)
21
+ * })
22
+ * ```
23
+ *
24
+ * @param requestUrl - The Obsidian requestUrl function
25
+ * @returns A CustomFetch function compatible with the Comms API SDK
26
+ */
27
+ export function createObsidianFetchAdapter(requestUrl) {
28
+ return async (url, options) => {
29
+ // Build the request parameters in Obsidian's format
30
+ const requestParams = {
31
+ url,
32
+ method: options?.method || 'GET',
33
+ headers: options?.headers,
34
+ body: options?.body,
35
+ throw: false, // Don't throw on HTTP errors; let the SDK handle status codes
36
+ };
37
+ // Make the request using Obsidian's requestUrl
38
+ const response = await requestUrl(requestParams);
39
+ // Transform Obsidian's response format to match CustomFetchResponse interface
40
+ return {
41
+ ok: response.status >= 200 && response.status < 300,
42
+ status: response.status,
43
+ statusText: '', // Obsidian doesn't provide statusText
44
+ headers: response.headers,
45
+ // Wrap Obsidian's direct properties as methods returning promises
46
+ text: () => Promise.resolve(response.text),
47
+ json: () => Promise.resolve(response.json),
48
+ };
49
+ };
50
+ }
@@ -0,0 +1,101 @@
1
+ import { getCommsBaseUri } from '../consts/endpoints.js';
2
+ export const TEST_API_TOKEN = 'test-api-token';
3
+ export const TEST_API_BASE_URL = getCommsBaseUri().replace(/\/$/, '');
4
+ // Canonical test IDs — shaped so they pass SDK-side validation.
5
+ export const TEST_CHANNEL_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3y';
6
+ export const TEST_THREAD_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3z';
7
+ export const TEST_COMMENT_ID = '7YpL3oZ4kZ9vP7Q1tR2sX41';
8
+ export const TEST_CONVERSATION_ID = '7YpL3oZ4kZ9vP7Q1tR2sX42';
9
+ export const TEST_MESSAGE_ID = '7YpL3oZ4kZ9vP7Q1tR2sX43';
10
+ export const TEST_GROUP_ID = '7YpL3oZ4kZ9vP7Q1tR2sX44';
11
+ export const mockUser = {
12
+ id: 1,
13
+ email: 'test@example.com',
14
+ fullName: 'Test User',
15
+ shortName: 'TU',
16
+ timezone: 'America/New_York',
17
+ removed: false,
18
+ lang: 'en',
19
+ };
20
+ export const mockWorkspace = {
21
+ id: 1,
22
+ name: 'Test Workspace',
23
+ creator: 1,
24
+ created: new Date('2021-01-01T00:00:00Z'),
25
+ };
26
+ export const mockChannel = {
27
+ id: TEST_CHANNEL_ID,
28
+ name: 'general',
29
+ creator: 1,
30
+ public: true,
31
+ workspaceId: 1,
32
+ archived: false,
33
+ created: new Date('2021-01-01T00:00:00Z'),
34
+ version: 0,
35
+ url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/`,
36
+ };
37
+ export const mockThread = {
38
+ id: TEST_THREAD_ID,
39
+ title: 'Test Thread',
40
+ content: 'This is a test thread',
41
+ creator: 1,
42
+ channelId: TEST_CHANNEL_ID,
43
+ workspaceId: 1,
44
+ commentCount: 0,
45
+ lastUpdated: new Date('2021-01-01T00:00:00Z'),
46
+ pinned: false,
47
+ posted: new Date('2021-01-01T00:00:00Z'),
48
+ snippet: 'This is a test thread',
49
+ snippetCreator: 1,
50
+ isArchived: false,
51
+ url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/`,
52
+ };
53
+ export const mockGroup = {
54
+ id: TEST_GROUP_ID,
55
+ name: 'Test Group',
56
+ workspaceId: 1,
57
+ userIds: [1, 2, 3],
58
+ version: 0,
59
+ };
60
+ export const mockConversation = {
61
+ id: TEST_CONVERSATION_ID,
62
+ workspaceId: 1,
63
+ userIds: [1, 2],
64
+ messageCount: 1,
65
+ lastObjIndex: 0,
66
+ snippet: 'Hello there',
67
+ snippetCreators: [1],
68
+ lastActive: new Date('2021-01-01T00:00:00Z'),
69
+ archived: false,
70
+ created: new Date('2021-01-01T00:00:00Z'),
71
+ creator: 1,
72
+ url: `https://comms.todoist.com/a/1/msg/${TEST_CONVERSATION_ID}/`,
73
+ };
74
+ export const mockComment = {
75
+ id: TEST_COMMENT_ID,
76
+ content: 'This is a comment',
77
+ creator: 1,
78
+ threadId: TEST_THREAD_ID,
79
+ workspaceId: 1,
80
+ channelId: TEST_CHANNEL_ID,
81
+ posted: new Date('2021-01-01T00:00:00Z'),
82
+ url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/c/${TEST_COMMENT_ID}`,
83
+ };
84
+ export const mockWorkspaceUser = {
85
+ id: 1,
86
+ fullName: 'Test User',
87
+ email: 'test@example.com',
88
+ userType: 'USER',
89
+ shortName: 'TU',
90
+ firstName: 'Test',
91
+ imageId: null,
92
+ avatarUrls: null,
93
+ dateFormat: null,
94
+ removed: false,
95
+ restricted: null,
96
+ setupPending: null,
97
+ theme: null,
98
+ timeFormat: null,
99
+ timezone: 'America/New_York',
100
+ version: 1,
101
+ };
@@ -0,0 +1,133 @@
1
+ import { CommsRequestError } from '../types/errors.js';
2
+ import { camelCaseKeys } from '../utils/case-conversion.js';
3
+ import { transformTimestamps } from '../utils/timestamp-conversion.js';
4
+ import { getDefaultDispatcher } from './http-dispatcher.js';
5
+ export async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
6
+ let lastError;
7
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
8
+ let clearTimeoutFn;
9
+ try {
10
+ let requestSignal = options.signal || undefined;
11
+ if (options.timeout && options.timeout > 0) {
12
+ const timeoutResult = createTimeoutSignal(options.timeout, requestSignal);
13
+ requestSignal = timeoutResult.signal;
14
+ clearTimeoutFn = timeoutResult.clear;
15
+ }
16
+ const response = customFetch
17
+ ? await customFetch(url, options)
18
+ : await fetchWithDefaultTransport(url, options, requestSignal);
19
+ const responseText = await response.text();
20
+ let responseData;
21
+ try {
22
+ responseData = responseText ? JSON.parse(responseText) : undefined;
23
+ }
24
+ catch {
25
+ responseData = responseText;
26
+ }
27
+ if (!response.ok) {
28
+ throw new CommsRequestError(`Request failed with status ${response.status}`, response.status, responseData);
29
+ }
30
+ const camelCased = camelCaseKeys(responseData);
31
+ const transformed = transformTimestamps(camelCased);
32
+ if (clearTimeoutFn) {
33
+ clearTimeoutFn();
34
+ }
35
+ return {
36
+ data: transformed,
37
+ status: response.status,
38
+ headers: response.headers,
39
+ };
40
+ }
41
+ catch (error) {
42
+ if (clearTimeoutFn) {
43
+ clearTimeoutFn();
44
+ }
45
+ lastError = error instanceof Error ? error : new Error('Unknown error');
46
+ if (attempt < maxRetries && isNetworkError(lastError)) {
47
+ const delay = getRetryDelay(attempt + 1);
48
+ if (delay > 0) {
49
+ await sleep(delay);
50
+ }
51
+ continue;
52
+ }
53
+ break;
54
+ }
55
+ }
56
+ if (lastError instanceof CommsRequestError) {
57
+ throw lastError;
58
+ }
59
+ throw new CommsRequestError(lastError?.message ?? 'Request failed');
60
+ }
61
+ async function fetchWithDefaultTransport(url, options, signal) {
62
+ const dispatcher = await getDefaultDispatcher();
63
+ const response = dispatcher
64
+ ? await fetch(url, {
65
+ ...options,
66
+ signal,
67
+ // @ts-expect-error - dispatcher is valid for Node.js fetch but not in TS types
68
+ dispatcher,
69
+ })
70
+ : await fetch(url, {
71
+ ...options,
72
+ signal,
73
+ });
74
+ return convertResponseToCustomFetch(response);
75
+ }
76
+ async function sleep(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+ const timeoutErrorName = 'TimeoutError';
80
+ function createTimeoutError(timeoutMs) {
81
+ const error = new Error(`Request timeout after ${timeoutMs}ms`);
82
+ error.name = timeoutErrorName;
83
+ return error;
84
+ }
85
+ function isNetworkError(error) {
86
+ return (error.name === 'TypeError' ||
87
+ error.name === timeoutErrorName ||
88
+ error.message.toLowerCase().includes('network'));
89
+ }
90
+ function getRetryDelay(retryCount) {
91
+ return retryCount === 1 ? 0 : 500;
92
+ }
93
+ function createTimeoutSignal(timeoutMs, existingSignal) {
94
+ const controller = new AbortController();
95
+ const timeoutId = setTimeout(() => {
96
+ controller.abort(createTimeoutError(timeoutMs));
97
+ }, timeoutMs);
98
+ let abortHandler;
99
+ function clear() {
100
+ clearTimeout(timeoutId);
101
+ if (existingSignal && abortHandler) {
102
+ existingSignal.removeEventListener('abort', abortHandler);
103
+ }
104
+ }
105
+ if (existingSignal) {
106
+ if (existingSignal.aborted) {
107
+ clearTimeout(timeoutId);
108
+ controller.abort(existingSignal.reason);
109
+ }
110
+ else {
111
+ abortHandler = () => {
112
+ clearTimeout(timeoutId);
113
+ controller.abort(existingSignal.reason);
114
+ };
115
+ existingSignal.addEventListener('abort', abortHandler, { once: true });
116
+ }
117
+ }
118
+ return { signal: controller.signal, clear };
119
+ }
120
+ function convertResponseToCustomFetch(response) {
121
+ const headers = {};
122
+ response.headers.forEach((value, key) => {
123
+ headers[key] = value;
124
+ });
125
+ return {
126
+ ok: response.ok,
127
+ status: response.status,
128
+ statusText: response.statusText,
129
+ headers,
130
+ text: () => response.clone().text(),
131
+ json: () => response.json(),
132
+ };
133
+ }
@@ -0,0 +1,51 @@
1
+ import { snakeCaseKeys } from '../utils/case-conversion.js';
2
+ import { fetchWithRetry } from './fetch-with-retry.js';
3
+ export function paramsSerializer(params) {
4
+ const qs = new URLSearchParams();
5
+ Object.keys(params).forEach((key) => {
6
+ const value = params[key];
7
+ if (value != null) {
8
+ if (Array.isArray(value)) {
9
+ qs.append(key, value.join(','));
10
+ }
11
+ else {
12
+ qs.append(key, String(value));
13
+ }
14
+ }
15
+ });
16
+ return qs.toString();
17
+ }
18
+ const defaultHeaders = {
19
+ 'Content-Type': 'application/json',
20
+ };
21
+ function getAuthHeader(apiToken) {
22
+ return `Bearer ${apiToken}`;
23
+ }
24
+ function getRequestConfiguration(baseURL, apiToken, requestId) {
25
+ const authHeader = apiToken ? { Authorization: getAuthHeader(apiToken) } : undefined;
26
+ const requestIdHeader = requestId ? { 'X-Request-Id': requestId } : undefined;
27
+ const headers = { ...defaultHeaders, ...authHeader, ...requestIdHeader };
28
+ return { baseURL, headers, timeout: 30000 };
29
+ }
30
+ export async function request(args) {
31
+ const { httpMethod, baseUri, relativePath, apiToken, payload, requestId, customFetch } = args;
32
+ const config = getRequestConfiguration(baseUri, apiToken, requestId);
33
+ const url = new URL(relativePath, config.baseURL).toString();
34
+ const options = {
35
+ method: httpMethod,
36
+ headers: config.headers,
37
+ timeout: config.timeout,
38
+ };
39
+ if (httpMethod === 'GET' && payload) {
40
+ const searchParams = paramsSerializer(snakeCaseKeys(payload));
41
+ const urlWithParams = searchParams ? `${url}?${searchParams}` : url;
42
+ return fetchWithRetry(urlWithParams, options, 3, customFetch);
43
+ }
44
+ if (payload && httpMethod !== 'GET') {
45
+ options.body = JSON.stringify(snakeCaseKeys(payload));
46
+ }
47
+ return fetchWithRetry(url, options, 3, customFetch);
48
+ }
49
+ export function isSuccess(response) {
50
+ return response.status >= 200 && response.status < 300;
51
+ }