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
5 changes: 2 additions & 3 deletions src/clients/add-comment-helper.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { getCommsBaseUri } from '../consts/endpoints'
import { apiUrl } from '../testUtils/msw-handlers'
import { server } from '../testUtils/msw-setup'
import { TEST_API_TOKEN, TEST_THREAD_ID } from '../testUtils/test-defaults'
import { TEST_API_BASE_URL, TEST_API_TOKEN, TEST_THREAD_ID } from '../testUtils/test-defaults'
import { EVERYONE, EVERYONE_IN_THREAD } from '../types/enums'
import { addCommentRequest } from './add-comment-helper'

const ctx = { baseUri: getCommsBaseUri(), apiToken: TEST_API_TOKEN }
const COMMENT_ADD = apiUrl('api/v1/comments/add')
const COMMENT_ADD = `${TEST_API_BASE_URL}/comments/add`

const COMMENT_RESPONSE = {
id: 'AAAAAAAAAAAAAAAAAAAAAA',
Expand Down
17 changes: 17 additions & 0 deletions src/clients/add-comment-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ function applyNotifyAudience(params: CreateCommentArgs): Omit<CreateCommentArgs,
return { ...rest, groups: [...(groups ?? []), sentinel] }
}

/**
* Internal helper that powers `comments.createComment`,
* `threads.closeThread`, and `threads.reopenThread`.
*
* Normalizes the `notifyAudience` flag into a sentinel `groups` entry,
* rejects sentinel IDs passed via `groups` / `directGroupMentions`, mints a
* UUIDv7 `id` when the caller omits one, and posts to `/comments/add`. When
* `threadAction` is set (`'close'` / `'reopen'`), it is forwarded on the
* wire so the same request both adds the comment and transitions the
* parent thread.
*
* @param context - Per-call client context (base URI, API token, optional `customFetch`).
* @param params - The comment payload (`{@link CreateCommentArgs}`).
* @param options - Optional configuration.
* @param options.threadAction - When set, also transitions the parent thread (`'close'` or `'reopen'`).
* @returns The parsed {@link Comment} returned by the API.
*/
export function addCommentRequest(
context: ClientContext,
params: CreateCommentArgs,
Expand Down
12 changes: 7 additions & 5 deletions src/clients/base-client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { getCommsBaseUri } from '../consts/endpoints'
import type { ApiVersion } from '../types/api-version'
import { DEFAULT_API_VERSION } from '../types/api-version'
import type { CustomFetch } from '../types/http'

export type ClientConfig = {
/** API token for authentication */
apiToken: string
/** Optional custom base URL. If not provided, uses the default Comms API URL */
baseUrl?: string
/** Optional API version. Defaults to 'v1' */
version?: ApiVersion
/** Optional custom fetch implementation for cross-platform compatibility */
customFetch?: CustomFetch
}
Expand All @@ -17,11 +21,13 @@ export type ClientConfig = {
export class BaseClient {
protected readonly apiToken: string
protected readonly baseUrl?: string
protected readonly defaultVersion: ApiVersion
protected readonly customFetch?: CustomFetch

constructor(config: ClientConfig) {
this.apiToken = config.apiToken
this.baseUrl = config.baseUrl
this.defaultVersion = config.version || DEFAULT_API_VERSION
this.customFetch = config.customFetch
}

Expand All @@ -30,10 +36,6 @@ export class BaseClient {
* slash so relative paths resolve cleanly through `URL`.
*/
protected getBaseUri(): string {
if (this.baseUrl) {
const normalizedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`
return `${normalizedBaseUrl}api/v1/`
}
return getCommsBaseUri()
return getCommsBaseUri(this.defaultVersion, this.baseUrl)
}
}
129 changes: 123 additions & 6 deletions src/clients/channels-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,20 @@ export const ChannelListSchema = z.array(ChannelSchema)
* to keep an optimistic-UI ID stable through the round-trip.
*/
export class ChannelsClient extends BaseClient {
/** Lists channels in a workspace. */
/**
* Gets all channels for a given workspace.
*
* @param args - The arguments for getting channels.
* @param args.workspaceId - The workspace ID.
* @param args.archived - Optional flag to include archived channels.
* @returns An array of channel objects.
*
* @example
* ```typescript
* const channels = await api.channels.getChannels({ workspaceId: 123 })
* channels.forEach(ch => console.log(ch.name))
* ```
*/
getChannels(args: GetChannelsArgs): Promise<Channel[]> {
return request<Channel[]>({
httpMethod: 'GET',
Expand All @@ -34,62 +47,166 @@ export class ChannelsClient extends BaseClient {
}).then((response) => ChannelListSchema.parse(response.data))
}

/** Fetches a single channel by ID. */
/**
* Gets a single channel object by id.
*
* @param id - The channel ID.
* @returns The channel object.
*/
getChannel(id: string): Promise<Channel> {
return this.simple('GET', 'getone', { id }, ChannelSchema)
}

/** Creates a new channel. `id` is auto-generated if not supplied. */
/**
* Creates a new channel. `id` is auto-generated if not supplied — pass your
* own `id` to keep an optimistic-UI ID stable through the round-trip.
*
* @param args - The arguments for creating a channel.
* @param args.workspaceId - The workspace ID.
* @param args.name - The channel name.
* @param args.description - Optional channel description.
* @param args.color - Optional channel color.
* @param args.userIds - Optional array of user IDs to add to the channel.
* @param args.public - Optional flag to make the channel public.
* @returns The created channel object.
*
* @example
* ```typescript
* const channel = await api.channels.createChannel({
* workspaceId: 123,
* name: 'Engineering',
* description: 'Engineering team channel',
* })
* ```
*/
createChannel(args: CreateChannelArgs): Promise<Channel> {
return this.simple('POST', 'add', { ...args, id: resolveCreateId(args.id) }, ChannelSchema)
}

/** Partial update of an existing channel. */
/**
* Partial update of an existing channel.
*
* @param args - The arguments for updating a channel.
* @param args.id - The channel ID.
* @param args.name - Optional new channel name.
* @param args.description - Optional new channel description.
* @param args.color - Optional new channel color.
* @param args.public - Optional flag to change channel visibility.
* @returns The updated channel object.
*/
updateChannel(args: UpdateChannelArgs): Promise<Channel> {
return this.simple('POST', 'update', { ...args }, ChannelSchema)
}

/** Updates the channel's view filter (`only_open` / `all` / `only_closed`). */
/**
* Updates the channel's view filter (`only_open` / `all` / `only_closed`).
*
* @param args - The arguments for updating the channel filter.
* @param args.id - The channel ID.
* @param args.filterClosed - The new filter value.
*/
updateFilters(args: {
id: string
filterClosed: 'only_open' | 'all' | 'only_closed'
}): Promise<StatusOk> {
return this.simple('POST', 'update_filters', { ...args }, StatusOkSchema)
}

/** Permanently deletes a channel. */
/**
* Permanently deletes a channel.
*
* @param id - The channel ID.
*/
deleteChannel(id: string): Promise<StatusOk> {
return this.simple('POST', 'remove', { id }, StatusOkSchema)
}

/**
* Archives a channel.
*
* @param id - The channel ID.
*/
archiveChannel(id: string): Promise<StatusOk> {
return this.simple('POST', 'archive', { id }, StatusOkSchema)
}

/**
* Unarchives a channel.
*
* @param id - The channel ID.
*/
unarchiveChannel(id: string): Promise<StatusOk> {
return this.simple('POST', 'unarchive', { id }, StatusOkSchema)
}

/**
* Favorites a channel.
*
* @param id - The channel ID.
*/
favoriteChannel(id: string): Promise<StatusOk> {
return this.simple('POST', 'favorite', { id }, StatusOkSchema)
}

/**
* Unfavorites a channel.
*
* @param id - The channel ID.
*/
unfavoriteChannel(id: string): Promise<StatusOk> {
return this.simple('POST', 'unfavorite', { id }, StatusOkSchema)
}

/**
* Adds a user to a channel.
*
* @param args - The arguments for adding a user.
* @param args.id - The channel ID.
* @param args.userId - The user ID to add.
*
* @example
* ```typescript
* await api.channels.addUser({ id: '7YpL3oZ4kZ9vP7Q1tR2sX44', userId: 101 })
* ```
*/
addUser(args: AddChannelUserArgs): Promise<Channel> {
return this.simple('POST', 'add_user', { ...args }, ChannelSchema)
}

/**
* Adds multiple users to a channel.
*
* @param args - The arguments for adding users.
* @param args.id - The channel ID.
* @param args.userIds - Array of user IDs to add.
*
* @example
* ```typescript
* await api.channels.addUsers({ id: '7YpL3oZ4kZ9vP7Q1tR2sX44', userIds: [101, 202] })
* ```
*/
addUsers(args: AddChannelUsersArgs): Promise<Channel> {
return this.simple('POST', 'add_users', { ...args }, ChannelSchema)
}

/**
* Removes a user from a channel.
*
* @param args - The arguments for removing a user.
* @param args.id - The channel ID.
* @param args.userId - The user ID to remove.
*/
removeUser(args: RemoveChannelUserArgs): Promise<Channel> {
return this.simple('POST', 'remove_user', { ...args }, ChannelSchema)
}

/**
* Removes multiple users from a channel.
*
* @param args - The arguments for removing users.
* @param args.id - The channel ID.
* @param args.userIds - Array of user IDs to remove.
*/
removeUsers(args: RemoveChannelUsersArgs): Promise<Channel> {
return this.simple('POST', 'remove_users', { ...args }, ChannelSchema)
}
Expand Down
9 changes: 6 additions & 3 deletions src/clients/comments-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { CommsApi } from '../comms-api'
import { server } from '../testUtils/msw-setup'
import { TEST_API_TOKEN, TEST_COMMENT_ID, TEST_THREAD_ID } from '../testUtils/test-defaults'

const BASE = 'https://comms.todoist.com/api/v1'
import {
TEST_API_BASE_URL as BASE,
TEST_API_TOKEN,
TEST_COMMENT_ID,
TEST_THREAD_ID,
} from '../testUtils/test-defaults'

// These tests pin the wire shape of `comments-client` — every camelCase
// field on the args side ends up snake_case on the wire (via
Expand Down
Loading
Loading