From 431191d00c1580249886e6549adc57c3677787ae Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 16:06:59 +0200 Subject: [PATCH 1/4] refactor: remove legacy Twist/migration framing Treat this SDK as a clean hard fork: drop README/website Twist branding and strip "as of the UUIDv7 migration" / "what's gone vs. legacy Twist" / `Comms_API_changes.md` references from docstrings and comments. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 44 ++++--------- src/clients/add-comment-helper.ts | 27 +++++--- src/clients/channels-client.ts | 18 ++---- src/clients/comments-client.ts | 22 +++---- src/clients/conversation-messages-client.ts | 36 ++++++----- src/clients/conversations-client.ts | 16 ++--- src/clients/groups-client.ts | 23 +++---- src/clients/inbox-client.ts | 5 +- src/clients/threads-client.ts | 17 ++--- src/clients/users-client.ts | 21 ++---- src/clients/workspace-users-client.ts | 10 +-- src/clients/workspaces-client.ts | 18 ++---- src/testUtils/test-defaults.ts | 4 +- src/types/entities.ts | 67 ++++++++------------ src/types/enums.ts | 14 ++-- src/types/requests.ts | 26 ++++---- src/utils/url-helpers.test.ts | 2 +- src/utils/url-helpers.ts | 4 -- src/utils/uuidv7.ts | 52 +++++++-------- website/docs/authorization.md | 51 ++++++++------- website/docs/index.md | 20 +++--- website/docusaurus.config.ts | 22 +++---- website/static/img/twist-logo.png | Bin 3892 -> 0 bytes 23 files changed, 220 insertions(+), 299 deletions(-) delete mode 100644 website/static/img/twist-logo.png diff --git a/README.md b/README.md index d1c0366..9016223 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,15 @@ const api = new CommsApi('YOUR_API_TOKEN', { }) ``` -### IDs are base58-encoded UUIDv7 +### Creating entities -As of the UUIDv7 migration, channel / thread / comment / conversation / -conversation-message / group IDs are **base58-encoded UUIDv7 strings**, not -integers. `workspaceId` and `userId` remain numeric. +Channel / thread / comment / conversation / message / group IDs are +opaque strings; `workspaceId` and `userId` are numeric. Creation endpoints (`createChannel`, `createThread`, `createComment`, -`getOrCreateConversation`, `createMessage`, `createGroup`) require the -caller to supply an `id`. If you don't, the SDK auto-generates one with -`generateId()`: +`getOrCreateConversation`, `createMessage`, `createGroup`) accept an +optional `id`. Pass your own to keep an optimistic-UI ID stable through +the round-trip, or let the SDK mint one with `generateId()`: ```typescript import { CommsApi, generateId } from '@doist/comms-sdk' @@ -53,8 +52,7 @@ const channel = await api.channels.createChannel({ name: 'Engineering', }) -// Option 2: mint the ID yourself (useful for optimistic UI — the local ID -// is the permanent ID; the brief unsynced window is the only difference) +// Option 2: mint the ID yourself const id = generateId() const sameChannel = await api.channels.createChannel({ workspaceId: 1, @@ -65,11 +63,10 @@ const sameChannel = await api.channels.createChannel({ ### Broadcast group markers -The legacy magic group IDs `1` (channel) and `2` (thread) are gone. Use the -string constants `EVERYONE` / `EVERYONE_IN_THREAD` when populating -`groups[]` / `directGroupMentions[]` directly, or use the `notifyAudience` -option on `createComment` / `closeThread` / `reopenThread` and let the SDK -encode it for you: +Use the string constants `EVERYONE` / `EVERYONE_IN_THREAD` when +populating `groups[]` / `directGroupMentions[]` directly, or pass +`notifyAudience` to `createComment` / `closeThread` / `reopenThread` +and let the SDK encode it for you: ```typescript await api.comments.createComment({ @@ -121,25 +118,6 @@ if (results[1].code === 200) console.log(results[1].data.fullName) GET-only batches run in parallel on the server. Mixed GET/POST batches run sequentially. -## What's gone vs. legacy Twist - -Per `Comms_API_changes.md`, several user / auth surfaces have been dropped -because authentication now flows through Todoist-ID: - -- `reset_password`, `register_with_google`, `connect_with_google`, - `disconnect_google`, `is_connected_to_google`, all `*_with_apple` - endpoints, all `email` management endpoints (`add_email`, - `confirm_email`, `remove_email`, etc.), and `login_with_provider`. -- User-model fields removed: `snooze_until` / `snooze_dnd_*`, `away_mode`, - `off_days`, `profession`, `contact_info`, `default_workspace`, `is_bot`, - `feature_flags`, `original_avatar_id`, `email_mask`. -- User-model fields renamed: `name` → `fullName`, `avatar_id` → `imageId`. -- User-model retypes: `theme` → `number`, `setupPending` → `boolean`. -- Workspace lost `default_channel`, `welcome_channel`, `security` (and - `color` is fixed at `1`). -- Thread `is_starred` → `is_saved` (and the matching `star` / `unstar` - endpoints are now `save` / `unsave`). - ## Development - `npm install` diff --git a/src/clients/add-comment-helper.ts b/src/clients/add-comment-helper.ts index 6602cfa..6962c9a 100644 --- a/src/clients/add-comment-helper.ts +++ b/src/clients/add-comment-helper.ts @@ -19,18 +19,29 @@ function isNotifyAudience(value: unknown): value is NotifyAudience { return typeof value === 'string' && (NOTIFY_AUDIENCES as readonly string[]).includes(value) } -function rejectMarkersIn(field: 'groups' | 'directGroupMentions', values: string[] | undefined) { - if (!values) return +function collectMarkerOffenses( + field: 'groups' | 'directGroupMentions', + values: readonly string[] | undefined, +): { field: string; offending: string[] } | null { + if (!values) return null const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id)) - if (offending.length === 0) return - throw new Error( - `\`${field}\` must not contain reserved broadcast marker IDs (${offending.join(', ')}). Use \`notifyAudience\` on createComment / closeThread / reopenThread, or import EVERYONE / EVERYONE_IN_THREAD from '@doist/comms-sdk' and treat the rejection as a typo.`, - ) + return offending.length > 0 ? { field, offending } : null } function applyNotifyAudience(params: CreateCommentArgs): Omit { - rejectMarkersIn('groups', params.groups ?? undefined) - rejectMarkersIn('directGroupMentions', params.directGroupMentions ?? undefined) + const offenses = [ + collectMarkerOffenses('groups', params.groups ?? undefined), + collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined), + ].filter((o): o is { field: string; offending: string[] } => o !== null) + + if (offenses.length > 0) { + const details = offenses + .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`) + .join('; ') + throw new Error( + `Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` on createComment / closeThread / reopenThread (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`, + ) + } if (params.notifyAudience == null) return params diff --git a/src/clients/channels-client.ts b/src/clients/channels-client.ts index 0414d5d..5f73e38 100644 --- a/src/clients/channels-client.ts +++ b/src/clients/channels-client.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { ENDPOINT_CHANNELS } from '../consts/endpoints' import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' -import { type Channel, ChannelSchema } from '../types/entities' +import { type Channel, ChannelSchema, type StatusOk, StatusOkSchema } from '../types/entities' import type { AddChannelUserArgs, AddChannelUsersArgs, @@ -15,16 +15,12 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const ChannelListSchema = z.array(ChannelSchema) /** - * Client for `/api/v3/channels/`. The channels module is the FastAPI port, - * so request bodies are JSON on POST. - * - * Channel IDs are base58-encoded UUIDv7 strings. The SDK auto-generates an - * `id` on `createChannel` when the caller doesn't supply one — pass your - * own `id` to keep an optimistic-UI ID stable through the round-trip. + * Client for `/api/v3/channels/`. The SDK auto-generates an `id` on + * `createChannel` when the caller doesn't supply one — pass your own `id` + * to keep an optimistic-UI ID stable through the round-trip. */ export class ChannelsClient extends BaseClient { /** Lists channels in a workspace. */ @@ -37,7 +33,7 @@ export class ChannelsClient extends BaseClient { const method = 'GET' const url = `${ENDPOINT_CHANNELS}/get` if (options?.batch) { - return { method, url, params: args, schema: z.array(ChannelSchema) } + return { method, url, params: args, schema: ChannelListSchema } } return request({ httpMethod: method, @@ -46,7 +42,7 @@ export class ChannelsClient extends BaseClient { apiToken: this.apiToken, payload: args, customFetch: this.customFetch, - }).then((response) => response.data.map((c) => ChannelSchema.parse(c))) + }).then((response) => ChannelListSchema.parse(response.data)) } /** Fetches a single channel by ID. */ diff --git a/src/clients/comments-client.ts b/src/clients/comments-client.ts index 106ae59..5705edd 100644 --- a/src/clients/comments-client.ts +++ b/src/clients/comments-client.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { ENDPOINT_COMMENTS } from '../consts/endpoints' import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' -import { type Comment, CommentSchema } from '../types/entities' +import { type Comment, CommentSchema, type StatusOk, StatusOkSchema } from '../types/entities' import type { CreateCommentArgs, GetCommentsArgs, @@ -12,13 +12,11 @@ import type { import { addCommentRequest } from './add-comment-helper' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const CommentListSchema = z.array(CommentSchema) /** - * Client for `/api/v3/comments/`. Comment IDs and thread IDs are - * base58-encoded UUIDv7 strings. The SDK auto-generates the comment `id` - * when the caller doesn't supply one. + * Client for `/api/v3/comments/`. The SDK auto-generates the comment `id` + * on `createComment` when the caller doesn't supply one. */ export class CommentsClient extends BaseClient { /** @@ -32,18 +30,18 @@ export class CommentsClient extends BaseClient { args: GetCommentsArgs, options?: { batch?: boolean }, ): Promise | BatchRequestDescriptor { - const params: Record = { thread_id: args.threadId } + const params: Record = { threadId: args.threadId } const newerThan = args.newerThan ?? args.from - if (newerThan) params.newer_than_ts = Math.floor(newerThan.getTime() / 1000) - if (args.olderThan) params.older_than_ts = Math.floor(args.olderThan.getTime() / 1000) + if (newerThan) params.newerThanTs = Math.floor(newerThan.getTime() / 1000) + if (args.olderThan) params.olderThanTs = Math.floor(args.olderThan.getTime() / 1000) if (args.limit) params.limit = args.limit const method = 'GET' const url = `${ENDPOINT_COMMENTS}/get` if (options?.batch) { - return { method, url, params, schema: z.array(CommentSchema) } + return { method, url, params, schema: CommentListSchema } } return request({ @@ -53,7 +51,7 @@ export class CommentsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((comment) => CommentSchema.parse(comment))) + }).then((response) => CommentListSchema.parse(response.data)) } /** Fetches a single comment by ID. The API wraps it in `{comment: ...}`. */ @@ -169,7 +167,7 @@ export class CommentsClient extends BaseClient { ): Promise | BatchRequestDescriptor { const method = 'POST' const url = `${ENDPOINT_COMMENTS}/mark_position` - const params = { thread_id: args.threadId, comment_id: args.commentId } + const params = { threadId: args.threadId, commentId: args.commentId } if (options?.batch) { return { method, url, params, schema: StatusOkSchema } diff --git a/src/clients/conversation-messages-client.ts b/src/clients/conversation-messages-client.ts index d75bd58..cd52cfc 100644 --- a/src/clients/conversation-messages-client.ts +++ b/src/clients/conversation-messages-client.ts @@ -2,7 +2,12 @@ import { z } from 'zod' import { ENDPOINT_CONVERSATION_MESSAGES } from '../consts/endpoints' import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' -import { type ConversationMessage, ConversationMessageSchema } from '../types/entities' +import { + type ConversationMessage, + ConversationMessageSchema, + type StatusOk, + StatusOkSchema, +} from '../types/entities' import type { CreateConversationMessageArgs, GetConversationMessagesArgs, @@ -11,12 +16,11 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const ConversationMessageListSchema = z.array(ConversationMessageSchema) /** - * Client for `/api/v3/conversation_messages/`. Message IDs and conversation - * IDs are both base58-encoded UUIDv7 strings on the wire. + * Client for `/api/v3/conversation_messages/`. The SDK auto-generates the + * message `id` on `createMessage` when the caller doesn't supply one. */ export class ConversationMessagesClient extends BaseClient { /** Lists messages in a conversation. */ @@ -32,9 +36,9 @@ export class ConversationMessagesClient extends BaseClient { args: GetConversationMessagesArgs, options?: { batch?: boolean }, ): Promise | BatchRequestDescriptor { - const params: Record = { conversation_id: args.conversationId } - if (args.newerThan) params.newer_than_ts = Math.floor(args.newerThan.getTime() / 1000) - if (args.olderThan) params.older_than_ts = Math.floor(args.olderThan.getTime() / 1000) + const params: Record = { conversationId: args.conversationId } + if (args.newerThan) params.newerThanTs = Math.floor(args.newerThan.getTime() / 1000) + if (args.olderThan) params.olderThanTs = Math.floor(args.olderThan.getTime() / 1000) if (args.limit) params.limit = args.limit if (args.cursor) params.cursor = args.cursor @@ -42,7 +46,7 @@ export class ConversationMessagesClient extends BaseClient { const url = `${ENDPOINT_CONVERSATION_MESSAGES}/get` if (options?.batch) { - return { method, url, params, schema: z.array(ConversationMessageSchema) } + return { method, url, params, schema: ConversationMessageListSchema } } return request({ @@ -52,9 +56,7 @@ export class ConversationMessagesClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => - response.data.map((message) => ConversationMessageSchema.parse(message)), - ) + }).then((response) => ConversationMessageListSchema.parse(response.data)) } /** Fetches a single message by ID. */ @@ -81,14 +83,14 @@ export class ConversationMessagesClient extends BaseClient { options?: { batch?: boolean }, ): Promise | BatchRequestDescriptor { const params: Record = { - conversation_id: args.conversationId, + conversationId: args.conversationId, content: args.content, id: resolveCreateId(args.id), } if (args.attachments) params.attachments = args.attachments if (args.actions) params.actions = args.actions - if (args.directMentions) params.direct_mentions = args.directMentions - if (args.directGroupMentions) params.direct_group_mentions = args.directGroupMentions + if (args.directMentions) params.directMentions = args.directMentions + if (args.directGroupMentions) params.directGroupMentions = args.directGroupMentions if (args.notify !== undefined) params.notify = args.notify return this.simple('POST', 'add', params, ConversationMessageSchema, options) @@ -110,8 +112,8 @@ export class ConversationMessagesClient extends BaseClient { const params: Record = { id: args.id, content: args.content } if (args.attachments) params.attachments = args.attachments if (args.actions) params.actions = args.actions - if (args.directMentions) params.direct_mentions = args.directMentions - if (args.directGroupMentions) params.direct_group_mentions = args.directGroupMentions + if (args.directMentions) params.directMentions = args.directMentions + if (args.directGroupMentions) params.directGroupMentions = args.directGroupMentions return this.simple('POST', 'update', params, ConversationMessageSchema, options) } diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index 54600d5..3b7eb5f 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -5,6 +5,8 @@ import type { BatchRequestDescriptor } from '../types/batch' import { type Conversation, ConversationSchema, + type StatusOk, + StatusOkSchema, type UnreadConversation, UnreadConversationSchema, } from '../types/entities' @@ -21,8 +23,7 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const ConversationListSchema = z.array(ConversationSchema) const GetUnreadResponseSchema = z.object({ data: z.array(UnreadConversationSchema), @@ -30,9 +31,8 @@ const GetUnreadResponseSchema = z.object({ }) /** - * Client for `/api/v3/conversations/`. Conversation IDs are - * base58-encoded UUIDv7 strings. `getOrCreate` requires an `id` (the SDK - * auto-generates one for new conversations); the backend dedupes on + * Client for `/api/v3/conversations/`. `getOrCreate` requires an `id` (the + * SDK auto-generates one for new conversations); the backend dedupes on * `userIds`, so an existing conversation will be returned with its own * already-assigned `id` and your generated one is silently dropped. */ @@ -55,7 +55,7 @@ export class ConversationsClient extends BaseClient { const params = args if (options?.batch) { - return { method, url, params, schema: z.array(ConversationSchema) } + return { method, url, params, schema: ConversationListSchema } } return request({ @@ -65,9 +65,7 @@ export class ConversationsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => - response.data.map((conversation) => ConversationSchema.parse(conversation)), - ) + }).then((response) => ConversationListSchema.parse(response.data)) } /** Fetches a single conversation by ID. */ diff --git a/src/clients/groups-client.ts b/src/clients/groups-client.ts index 520381d..b4da3ce 100644 --- a/src/clients/groups-client.ts +++ b/src/clients/groups-client.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { ENDPOINT_GROUPS } from '../consts/endpoints' import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' -import { type Group, GroupSchema } from '../types/entities' +import { type Group, GroupSchema, type StatusOk, StatusOkSchema } from '../types/entities' import type { AddGroupUserArgs, AddGroupUsersArgs, @@ -12,19 +12,16 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const GroupListSchema = z.array(GroupSchema) /** - * Client for `/api/v3/groups/`. + * Client for `/api/v3/groups/`. The broadcast markers `EVERYONE` / + * `EVERYONE_IN_THREAD` are NOT addressable through these endpoints — they + * only appear as members of `direct_group_mentions` / `groups` lists on + * thread/comment writes. * - * Group IDs are base58-encoded UUIDv7 strings. The special markers - * `EVERYONE` / `EVERYONE_IN_THREAD` are NOT addressable through these - * endpoints — they only appear as members of `direct_group_mentions` / - * `groups` lists on thread/comment writes. - * - * Per the backend contract, `getone` / `update` / `remove` / member ops all - * require `workspace_id` alongside the group `id`. + * `getone` / `update` / `remove` and the member-management ops all require + * `workspace_id` alongside the group `id`. */ export class GroupsClient extends BaseClient { /** Lists groups in a workspace. */ @@ -39,7 +36,7 @@ export class GroupsClient extends BaseClient { const params = { workspaceId } if (options?.batch) { - return { method, url, params, schema: z.array(GroupSchema) } + return { method, url, params, schema: GroupListSchema } } return request({ @@ -49,7 +46,7 @@ export class GroupsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((group) => GroupSchema.parse(group))) + }).then((response) => GroupListSchema.parse(response.data)) } /** Fetches a single group by ID (requires `workspaceId`). */ diff --git a/src/clients/inbox-client.ts b/src/clients/inbox-client.ts index 2e1ab56..c024353 100644 --- a/src/clients/inbox-client.ts +++ b/src/clients/inbox-client.ts @@ -11,10 +11,7 @@ type InboxCountResponse = { version: number } -/** - * Client for `/api/v3/inbox/`. Thread IDs are base58-encoded UUIDv7 - * strings. - */ +/** Client for `/api/v3/inbox/`. */ export class InboxClient extends BaseClient { /** * Gets inbox items (threads). diff --git a/src/clients/threads-client.ts b/src/clients/threads-client.ts index 6edb121..c06faf0 100644 --- a/src/clients/threads-client.ts +++ b/src/clients/threads-client.ts @@ -4,6 +4,8 @@ import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' import { type Comment, + type StatusOk, + StatusOkSchema, type Thread, ThreadSchema, type UnreadThread, @@ -26,8 +28,7 @@ import { resolveCreateId } from '../utils/uuidv7' import { addCommentRequest } from './add-comment-helper' import { BaseClient } from './base-client' -const StatusOkSchema = z.object({ status: z.string() }) -type StatusOk = z.infer +const ThreadListSchema = z.array(ThreadSchema) const GetUnreadResponseSchema = z.object({ data: z.array(UnreadThreadSchema), @@ -36,12 +37,8 @@ const GetUnreadResponseSchema = z.object({ }) /** - * Client for `/api/v3/threads/`. - * - * Thread IDs and channel IDs are base58-encoded UUIDv7 strings. The SDK - * auto-generates the thread `id` on `createThread` when the caller doesn't - * supply one. `is_starred` / `star` are gone — use `save` / `unsave` (a.k.a. - * `isSaved` in JSON). + * Client for `/api/v3/threads/`. The SDK auto-generates the thread `id` on + * `createThread` when the caller doesn't supply one. */ export class ThreadsClient extends BaseClient { /** @@ -67,7 +64,7 @@ export class ThreadsClient extends BaseClient { } if (options?.batch) { - return { method, url, params, schema: z.array(ThreadSchema) } + return { method, url, params, schema: ThreadListSchema } } return request({ @@ -77,7 +74,7 @@ export class ThreadsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((thread) => ThreadSchema.parse(thread))) + }).then((response) => ThreadListSchema.parse(response.data)) } /** Fetches a single thread by ID. */ diff --git a/src/clients/users-client.ts b/src/clients/users-client.ts index 02d1968..149c522 100644 --- a/src/clients/users-client.ts +++ b/src/clients/users-client.ts @@ -41,20 +41,12 @@ type MfaChallengeArgs = { } /** - * Client for the `/api/v3/users/` endpoints. - * - * As of Comms, authentication is routed through Todoist-ID. The classic - * email-management endpoints (`add_email`, `confirm_email`, …) and the - * Google/Apple OAuth account-linking endpoints have been removed. `register` - * / `login` / `login_with_google` / `login_with_token` / - * `login_with_todoist` are the only auth entry points. - * - * @see Comms_API_changes.md — Removed API Endpoints + * Client for the `/api/v3/users/` endpoints. Authentication flows through + * Todoist-ID; `register` / `login` / `loginWithGoogle` / `loginWithToken` / + * `loginWithTodoist` are the available entry points. */ export class UsersClient extends BaseClient { - /** - * Registers a new user via the Todoist-ID bridge. - */ + /** Registers a new user via the Todoist-ID bridge. */ register(args: RegisterArgs, options: { batch: true }): BatchRequestDescriptor register(args: RegisterArgs, options?: { batch?: false }): Promise register( @@ -215,10 +207,7 @@ export class UsersClient extends BaseClient { return this.authedPost(`${ENDPOINT_USERS}/update`, args, UserSchema, options) } - /** - * Updates the user's password. Requires `currentPassword` (matches the - * Todoist contract; see PR #181 in `Comms_API_changes.md`). - */ + /** Updates the user's password. Requires `currentPassword`. */ updatePassword( args: { newPassword: string; currentPassword?: string }, options: { batch: true }, diff --git a/src/clients/workspace-users-client.ts b/src/clients/workspace-users-client.ts index 3861e78..9116830 100644 --- a/src/clients/workspace-users-client.ts +++ b/src/clients/workspace-users-client.ts @@ -12,14 +12,8 @@ import type { import { BaseClient } from './base-client' /** - * Client for `/api/v3/workspace_users/`. Every endpoint is also aliased - * onto `/api/v3/workspaces/_user[...]` for legacy compatibility; this - * client uses the canonical `workspace_users/...` paths. - * - * Note: the backend's `add` endpoint currently rejects non-empty `name` and - * `channelIds` — invitee names and invite-to-channels were intentionally - * dropped. The args here are kept for forward-compatibility but the backend - * will return `BAD_REQUEST` if you populate them. + * Client for `/api/v3/workspace_users/`. The backend's `add` endpoint + * rejects non-empty `name` and `channelIds` — set neither. */ export class WorkspaceUsersClient extends BaseClient { /** diff --git a/src/clients/workspaces-client.ts b/src/clients/workspaces-client.ts index 03f3def..1407285 100644 --- a/src/clients/workspaces-client.ts +++ b/src/clients/workspaces-client.ts @@ -1,17 +1,15 @@ +import { z } from 'zod' import { ENDPOINT_WORKSPACES } from '../consts/endpoints' import { request } from '../transport/http-client' import type { BatchRequestDescriptor } from '../types/batch' import { Channel, ChannelSchema, Workspace, WorkspaceSchema } from '../types/entities' import { BaseClient } from './base-client' +const ChannelListSchema = z.array(ChannelSchema) + /** - * Client for `/api/v3/workspaces/`. Workspace IDs are integers (not - * migrated to UUIDv7). - * - * Note: Comms' workspaces have lost their `color`, `default_channel`, - * `welcome_channel`, and `security` fields per the Todoist-id migration - * (`Comms_API_changes.md`). The backend currently rejects any `color` other - * than `1` on add/update. + * Client for `/api/v3/workspaces/`. Workspace IDs are integers. The backend + * currently rejects any `color` other than `1` on add/update. */ export class WorkspacesClient extends BaseClient { /** @@ -125,7 +123,6 @@ export class WorkspacesClient extends BaseClient { * Creates a new workspace. * * @param name - The name of the new workspace. - * @param tempId - Optional temporary ID for the workspace. * @param options - Optional configuration. Set `batch: true` to return a descriptor for batch requests. * @returns The created workspace object. * @@ -207,7 +204,6 @@ export class WorkspacesClient extends BaseClient { * Removes a workspace and all its data (not recoverable). * * @param id - The workspace ID. - * @param currentPassword - The user's current password for confirmation. * @param options - Optional configuration. Set `batch: true` to return a descriptor for batch requests. * * @example @@ -263,7 +259,7 @@ export class WorkspacesClient extends BaseClient { const params = { id } if (options?.batch) { - return { method, url, params } + return { method, url, params, schema: ChannelListSchema } } return request({ @@ -273,6 +269,6 @@ export class WorkspacesClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((channel) => ChannelSchema.parse(channel))) + }).then((response) => ChannelListSchema.parse(response.data)) } } diff --git a/src/testUtils/test-defaults.ts b/src/testUtils/test-defaults.ts index 9b8782d..bc1b1fd 100644 --- a/src/testUtils/test-defaults.ts +++ b/src/testUtils/test-defaults.ts @@ -11,9 +11,7 @@ import type { export const TEST_API_TOKEN = 'test-api-token' -// Canonical test IDs. Real base58-encoded UUIDv7s so they pass any future -// SDK-side validation; the prefix `7Y` is a hint that this is a v7-derived -// timestamp in the recent past (year 2024-ish). +// Canonical test IDs — shaped so they pass SDK-side validation. export const TEST_CHANNEL_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3y' export const TEST_THREAD_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3z' export const TEST_COMMENT_ID = '7YpL3oZ4kZ9vP7Q1tR2sX41' diff --git a/src/types/entities.ts b/src/types/entities.ts index fd3f2b4..34744dc 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -6,13 +6,8 @@ import { USER_TYPES, WORKSPACE_PLANS } from './enums' * Marker constants for the two "broadcast" group recipients. These appear in * group-bearing fields (`Thread.groups`, `Comment.groups`, * `Channel.defaultGroups`, `directGroupMentions`, etc.) in place of a real - * group UUID and tell the backend to notify "everyone in the channel" or + * group ID and tell the backend to notify "everyone in the channel" or * "everyone in the thread" respectively. Input and output are symmetric. - * - * The string values (`'EVERYONE'`, `'EVERYONE_IN_THREAD'`) are the on-wire - * constants the backend accepts. - * - * @see Comms_API_changes.md — Group Model Changes */ export const EVERYONE = 'EVERYONE' as const export const EVERYONE_IN_THREAD = 'EVERYONE_IN_THREAD' as const @@ -22,17 +17,24 @@ export const GROUP_ID_MARKERS = [EVERYONE, EVERYONE_IN_THREAD] as const export type GroupIdMarker = (typeof GROUP_ID_MARKERS)[number] /** - * A group identifier on the wire. Either a base58-encoded UUIDv7 string for - * a real group, or one of the {@link GROUP_ID_MARKERS} for a broadcast - * audience. + * A group identifier on the wire — either an opaque group ID or one of the + * {@link GROUP_ID_MARKERS} for a broadcast audience. */ export type GroupId = string // Reusable schema for system messages that can be either a string or an -// object. Always nullable — the backend returns `null` when there is no -// system message (post-PR316 it no longer returns `""`). +// object. Nullable — the backend returns `null` when there is no system +// message. export const SystemMessageSchema = z.union([z.string(), z.unknown()]).nullable().optional() +/** + * Shared `{ status: "ok" }` response shape. Most write endpoints that + * don't return an entity use this — archive / unarchive / mark-read / + * mark-all-read / mute / clear-unread / etc. + */ +export const StatusOkSchema = z.object({ status: z.string() }) +export type StatusOk = z.infer + // Attachment entity from API. Mirrors the canonical backend shape produced // by `unify_attachments` / `validate_file_attachment_json`. Only // `attachmentId` and `urlType` are guaranteed; everything else depends on @@ -62,10 +64,8 @@ export const AttachmentSchema = z export type Attachment = z.infer -// Base user schema with common fields shared between User and WorkspaceUser. -// As of the Todoist-id migration, profile fields (full_name, image_id, -// avatar URLs) flow from Todoist rather than being owned by Comms — they're -// still surfaced here for convenience. +// Fields shared by `User` and `WorkspaceUser`. Profile fields +// (`fullName`, `imageId`, avatar URLs) are sourced from Todoist-ID. export const BaseUserSchema = z.object({ id: z.number(), fullName: z.string(), @@ -100,8 +100,6 @@ export const UserSchema = BaseUserSchema.extend({ export type User = z.infer -// Workspace entity from API. `default_channel` / `welcome_channel` / -// `security` were removed as part of the Todoist-model migration. export const WorkspaceSchema = z.object({ id: z.number(), name: z.string(), @@ -123,7 +121,6 @@ export const WorkspaceSchema = z.object({ export type Workspace = z.infer -// Channel entity from API. `id` is a base58-encoded UUIDv7 string. export const ChannelSchema = z .object({ id: z.string(), @@ -151,10 +148,8 @@ export const ChannelSchema = z export type Channel = z.infer -// Thread entity from API. `id` / `channelId` are base58-encoded UUIDv7 -// strings. The REST shape keeps the legacy `pinned` bool for -// Zapier/webhook compatibility alongside the canonical `pinnedTs` -// (epoch ms or null). +// Thread entity from API. `pinned` (boolean) and `pinnedTs` (epoch ms or +// null) are both surfaced — `pinned` is kept for Zapier/webhook clients. export const ThreadSchema = z .object({ id: z.string(), @@ -230,10 +225,9 @@ export const ThreadSchema = z export type Thread = z.infer -// Group entity from API. `id` is a base58-encoded UUIDv7 string. Two -// special constants — `EVERYONE` and `EVERYONE_IN_THREAD` — can also appear -// in place of a real `id` in group-bearing fields on threads/comments/ -// channels. +// Group entity from API. The broadcast markers `EVERYONE` and +// `EVERYONE_IN_THREAD` can appear in place of a real `id` in group-bearing +// fields on threads/comments/channels. export const GroupSchema = z.object({ id: z.string(), name: z.string(), @@ -245,8 +239,7 @@ export const GroupSchema = z.object({ export type Group = z.infer -// Conversation entity from API. `id` is a base58-encoded UUIDv7 string; -// conversation_messages still use integer IDs until that migration lands. +// Conversation entity from API. export const ConversationSchema = z .object({ id: z.string(), @@ -265,7 +258,7 @@ export const ConversationSchema = z private: z.boolean().nullable().optional(), lastMessage: z .object({ - // Widened pending PR5 — see ConversationMessageSchema. + // `id` may be string or number depending on the endpoint; coerced. id: z.union([z.string(), z.number()]).transform(String), content: z.string(), creator: z.number(), @@ -293,8 +286,6 @@ export const ConversationSchema = z export type Conversation = z.infer -// Comment entity from API. `id`, `threadId`, `channelId`, and (when -// present) `conversationId` are base58-encoded UUIDv7 strings. export const CommentSchema = z .object({ id: z.string(), @@ -333,7 +324,6 @@ export const CommentSchema = z export type Comment = z.infer -// WorkspaceUser entity from v4 API. export const WorkspaceUserSchema = BaseUserSchema.extend({ email: z.string().nullable().optional(), userType: z.enum(USER_TYPES), @@ -345,15 +335,10 @@ export const WorkspaceUserSchema = BaseUserSchema.extend({ export type WorkspaceUser = z.infer -// ConversationMessage entity from API. -// -// PR5 of the UUIDv7 migration hasn't landed yet — the DB column is still -// `int`, even though the API input layer accepts base58 strings via -// `validate_uuid_arg`. To absorb either response shape during the -// rollout, `id` is widened to `string | number` here (coerced to a -// string post-parse) and the matching reaction / URL helpers accept -// both. Tighten this to `z.string()` once PR5 ships and the response -// serializer is confirmed to emit strings everywhere. +// ConversationMessage entity from API. `id` is widened to `string | number` +// (coerced to a string post-parse) because the backend currently emits +// either shape depending on the endpoint; the URL/reaction helpers accept +// both. export const ConversationMessageSchema = z .object({ id: z.union([z.string(), z.number()]).transform(String), diff --git a/src/types/enums.ts b/src/types/enums.ts index 5a39bd3..0d96ca8 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -43,15 +43,11 @@ export const NOTIFY_AUDIENCES = ['channel', 'thread'] as const export type NotifyAudience = (typeof NOTIFY_AUDIENCES)[number] /** - * Internal mapping from {@link NotifyAudience} to the backend marker IDs - * that comment / thread creation endpoints use on the wire (`EVERYONE` and - * `EVERYONE_IN_THREAD`). Exposed here so the audience constants and their - * encoding stay in a single source of truth; SDK consumers should use - * {@link NotifyAudience} via `notifyAudience` on the request args rather - * than passing these IDs directly. - * - * Per `Comms_API_changes.md`, the prior numeric IDs `1` / `2` were replaced - * with string constants on the wire. + * Internal mapping from {@link NotifyAudience} to the broadcast marker IDs + * (`EVERYONE` / `EVERYONE_IN_THREAD`) that comment- and thread-creation + * endpoints use on the wire. SDK consumers should use {@link NotifyAudience} + * via `notifyAudience` on the request args rather than passing these IDs + * directly. */ export const NOTIFY_AUDIENCE_GROUP_IDS: Readonly> = { channel: EVERYONE, diff --git a/src/types/requests.ts b/src/types/requests.ts index 29f36ad..01f908a 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -3,11 +3,11 @@ import { type Attachment, AttachmentSchema } from './entities' import { NOTIFY_AUDIENCES } from './enums' /** - * Create-side requests now require an `id` that's a base58-encoded UUIDv7 - * string (per the migration). The SDK clients accept `id` as optional and - * auto-generate one via {@link import('../utils/uuidv7').generateId} when - * the caller doesn't pass one — callers can still mint their own ID locally - * (e.g. for optimistic UI) and have it stick. + * Create-side requests require an `id` on the wire. The SDK clients accept + * `id` as optional and auto-generate one via + * {@link import('../utils/uuidv7').generateId} when the caller doesn't pass + * one — callers can still mint their own ID locally (e.g. for optimistic + * UI) and have it stick. */ export const CreateChannelArgsSchema = z.object({ @@ -167,9 +167,9 @@ export const GetOrCreateConversationArgsSchema = z.object({ export type GetOrCreateConversationArgs = z.infer -// Users — only the fields still supported after the Todoist-id migration. -// `password`/`current_password` are wired through Todoist; profile fields -// (full_name, image, etc.) round-trip through `/users/update` here. +// Users. `password`/`current_password` are wired through Todoist-ID; +// profile fields (full_name, image, etc.) round-trip through +// `/users/update` here. export type UpdateUserArgs = { name?: string password?: string @@ -222,7 +222,7 @@ export type GetConversationMessagesArgs = { export type CreateConversationMessageArgs = { conversationId: string content: string - /** Caller-supplied base58 UUIDv7. Auto-generated if omitted. */ + /** Caller-supplied ID. Auto-generated if omitted. */ id?: string attachments?: Attachment[] actions?: unknown[] @@ -267,7 +267,7 @@ export type ArchiveAllArgs = { since?: Date } -// Reactions — all targets are base58 UUIDv7 strings. +// Reactions. export type AddReactionArgs = { threadId?: string commentId?: string @@ -387,9 +387,9 @@ export type MuteConversationArgs = { minutes: number } -// Groups — IDs are base58 UUIDv7 strings; `EVERYONE` / `EVERYONE_IN_THREAD` -// can also appear in group-bearing fields on threads/comments/channels but -// not in these group-management endpoints (those address a concrete group). +// Groups. The broadcast markers `EVERYONE` / `EVERYONE_IN_THREAD` are not +// addressable through these endpoints — they only appear in group-bearing +// fields on threads/comments/channels. export type AddGroupUserArgs = { id: string workspaceId: number diff --git a/src/utils/url-helpers.test.ts b/src/utils/url-helpers.test.ts index 637e198..94b001c 100644 --- a/src/utils/url-helpers.test.ts +++ b/src/utils/url-helpers.test.ts @@ -18,7 +18,7 @@ import { getUserProfileURL, } from './url-helpers' -// Real-shaped IDs (base58-encoded UUIDv7) so assertions read like prod URLs. +// Real-shaped IDs so assertions read like prod URLs. const CH = '7YpL3oZ4kZ9vP7Q1tR2sX3y' const TH = '7YpL3oZ4kZ9vP7Q1tR2sX3z' const CO = '7YpL3oZ4kZ9vP7Q1tR2sX41' diff --git a/src/utils/url-helpers.ts b/src/utils/url-helpers.ts index ef8fd19..0576964 100644 --- a/src/utils/url-helpers.ts +++ b/src/utils/url-helpers.ts @@ -1,9 +1,5 @@ /** * Helper functions for creating Comms permalinks (`https://comms.todoist.com/a/...`). - * - * As of the UUIDv7 migration, channel / thread / comment / conversation IDs - * are base58-encoded strings. `workspaceId`, `userId`, and conversation - * `messageId` remain numeric. */ export type CommsURLParams = { diff --git a/src/utils/uuidv7.ts b/src/utils/uuidv7.ts index d4d8724..f6ea8d3 100644 --- a/src/utils/uuidv7.ts +++ b/src/utils/uuidv7.ts @@ -1,14 +1,13 @@ import { v7 as uuidv7 } from 'uuid' /** - * Comms' wire format for IDs of migrated entities (channels, threads, - * comments, conversations, groups) is a base58-encoded UUIDv7. The encoding - * mirrors the backend's `twist.apps.uuidv7` module so callers can mint IDs - * locally and pass them to creation endpoints. + * ID utilities for entities that use opaque string identifiers (channels, + * threads, comments, conversations, messages, groups). * - * Use {@link generateId} to mint a new ID. Use {@link encodeUuidToBase58} / - * {@link decodeBase58ToUuidBytes} to convert between raw UUID bytes and the - * wire format if you need to round-trip them yourself. + * Use {@link generateId} to mint a new ID locally and pass it to a creation + * endpoint. {@link encodeUuidToBase58} / {@link decodeBase58ToUuidBytes} + * expose the underlying encoding for callers that need to round-trip raw + * UUID bytes themselves. */ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' @@ -32,9 +31,7 @@ export class UuidV7Error extends Error { } } -/** - * Encode a 16-byte UUID to a base58 string using Comms' alphabet. - */ +/** Encode a 16-byte UUID as a base58 string. */ export function encodeUuidToBase58(bytes: Uint8Array): string { if (bytes.length !== UUID_BYTES_LEN) { throw new UuidV7Error(`id must be ${UUID_BYTES_LEN} bytes`) @@ -107,15 +104,15 @@ export function decodeBase58ToUuidBytes(value: string): Uint8Array { function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2) for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.substr(i * 2, 2), 16) + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) } return out } /** - * Mint a new base58-encoded UUIDv7. Callers should generate one of these - * locally when creating a new channel / thread / comment / conversation; the - * backend requires the client to supply the ID on create. + * Mint a fresh ID. Callers should generate one of these locally when + * creating a new channel / thread / comment / conversation / message / + * group — the backend requires the client to supply the ID on create. */ export function generateId(): string { const hex = uuidv7().replace(/-/g, '') @@ -123,27 +120,24 @@ export function generateId(): string { } /** - * Resolve the `id` for a create-style API call: either validate the - * caller-supplied value (rejecting with a clear {@link UuidV7Error} before - * the request leaves the SDK) or mint a fresh one via {@link generateId}. - * Used internally by the create methods on `channels`, `threads`, - * `comments`, `conversations`, `groups`, and `conversation_messages`. + * Resolve the `id` for a create-style API call: validate the caller-supplied + * value (throwing {@link UuidV7Error} before the request leaves the SDK) or + * mint a fresh one via {@link generateId}. */ export function resolveCreateId(id: string | undefined): string { if (id === undefined) return generateId() if (!isValidUuidV7Base58(id)) { throw new UuidV7Error( - `id must be a base58-encoded UUIDv7 string; got ${JSON.stringify(id)}. ` + - 'Use generateId() or omit `id` and let the SDK mint one.', + `invalid id ${JSON.stringify(id)} — use generateId() or omit \`id\` and let the SDK mint one.`, ) } return id } /** - * Validate that a value is a syntactically valid base58-encoded UUIDv7 (i.e. - * the bytes have the v7 version nibble + RFC 4122/9562 variant bits). Does - * NOT validate the timestamp window — the backend may still reject a value + * Validate that a value matches the expected ID format (the decoded bytes + * have the v7 version nibble + RFC 4122/9562 variant bits). Does NOT + * validate the embedded timestamp — the backend may still reject a value * that is too far in the future or past. */ export function isValidUuidV7Base58(value: unknown): value is string { @@ -160,9 +154,9 @@ export function isValidUuidV7Base58(value: unknown): value is string { } /** - * Round-trip helper: take a UUID string (hyphenated or not, any case) and - * encode its 16 raw bytes as base58. Useful when interoperating with - * non-Comms systems that hand you UUIDs in canonical form. + * Encode a canonical UUID string (hyphenated or not, any case) as a + * wire-format ID. Useful when interoperating with systems that hand you + * UUIDs in canonical form. */ export function base58FromUuidString(uuid: string): string { const stripped = uuid.replace(/-/g, '').toLowerCase() @@ -173,8 +167,8 @@ export function base58FromUuidString(uuid: string): string { } /** - * Inverse of {@link base58FromUuidString}: takes a base58 ID and returns the - * canonical hyphenated UUID string. + * Inverse of {@link base58FromUuidString}: takes a wire-format ID and + * returns the canonical hyphenated UUID string. */ export function uuidStringFromBase58(value: string): string { const bytes = decodeBase58ToUuidBytes(value) diff --git a/website/docs/authorization.md b/website/docs/authorization.md index f317430..e50e4d3 100644 --- a/website/docs/authorization.md +++ b/website/docs/authorization.md @@ -5,16 +5,16 @@ sidebar_position: 2 # Authorization -The Twist SDK provides helper functions to implement OAuth 2.0 authentication for your application. This allows you to obtain access tokens on behalf of users to interact with the Twist API. +The Comms SDK provides helper functions to implement OAuth 2.0 authentication for your application. This allows you to obtain access tokens on behalf of users to interact with the Comms API. ## Quick Start For quick testing and development, you can use an API token directly: ```typescript -import { TwistApi } from '@doist/twist-sdk' +import { CommsApi } from '@doist/comms-sdk' -const api = new TwistApi('your-api-token') +const api = new CommsApi('your-api-token') const user = await api.users.getSessionUser() ``` @@ -29,7 +29,7 @@ The SDK provides three main functions to handle OAuth authentication: First, generate a secure random state parameter to prevent CSRF attacks: ```typescript -import { getAuthStateParameter } from '@doist/twist-sdk' +import { getAuthStateParameter } from '@doist/comms-sdk' const state = getAuthStateParameter() // Returns a UUID v4 string like: "550e8400-e29b-41d4-a716-446655440000" @@ -43,9 +43,9 @@ req.session.oauthState = state Create the URL to redirect users to for authorization: ```typescript -import { getAuthorizationUrl, TwistScope } from '@doist/twist-sdk' +import { getAuthorizationUrl, CommsScope } from '@doist/comms-sdk' -const scopes: TwistScope[] = ['user:read', 'workspaces:read', 'channels:read', 'threads:write'] +const scopes: CommsScope[] = ['user:read', 'workspaces:read', 'channels:read', 'threads:write'] const authUrl = getAuthorizationUrl( 'your-client-id', // Your OAuth client ID @@ -63,7 +63,7 @@ res.redirect(authUrl) ```typescript getAuthorizationUrl( clientId: string, - scopes: TwistScope[], + scopes: CommsScope[], state: string, redirectUri?: string, baseUrl?: string, @@ -72,10 +72,10 @@ getAuthorizationUrl( ### 3. Exchange Authorization Code for Access Token -After the user authorizes your app, Twist redirects them back to your redirect URI with an authorization code. Exchange this code for an access token: +After the user authorizes your app, Comms redirects them back to your redirect URI with an authorization code. Exchange this code for an access token: ```typescript -import { getAuthToken } from '@doist/twist-sdk' +import { getAuthToken } from '@doist/comms-sdk' // In your OAuth callback handler: app.get('/callback', async (req, res) => { @@ -99,10 +99,10 @@ app.get('/callback', async (req, res) => { req.session.accessToken = tokenResponse.accessToken // Now you can use the SDK - const api = new TwistApi(tokenResponse.accessToken) + const api = new CommsApi(tokenResponse.accessToken) const user = await api.users.getSessionUser() - res.send(`Hello ${user.name}!`) + res.send(`Hello ${user.fullName}!`) }) ``` @@ -131,7 +131,7 @@ getAuthToken( When a user logs out or revokes access, you can revoke the token: ```typescript -import { revokeAuthToken } from '@doist/twist-sdk' +import { revokeAuthToken } from '@doist/comms-sdk' const success = await revokeAuthToken({ clientId: 'your-client-id', @@ -159,7 +159,7 @@ revokeAuthToken( ## Available Scopes -The SDK includes a `TwistScope` type that defines all available OAuth scopes: +The SDK includes a `CommsScope` type that defines all available OAuth scopes: ### User Scopes @@ -234,9 +234,9 @@ import { getAuthStateParameter, getAuthorizationUrl, getAuthToken, - TwistApi, - TwistScope, -} from '@doist/twist-sdk' + CommsApi, + CommsScope, +} from '@doist/comms-sdk' const app = express() @@ -249,14 +249,14 @@ app.use( ) // Step 1: Initiate OAuth flow -app.get('/auth/twist', (req, res) => { +app.get('/auth/comms', (req, res) => { const state = getAuthStateParameter() req.session.oauthState = state - const scopes: TwistScope[] = ['user:read', 'workspaces:read', 'channels:read', 'threads:write'] + const scopes: CommsScope[] = ['user:read', 'workspaces:read', 'channels:read', 'threads:write'] const authUrl = getAuthorizationUrl( - process.env.TWIST_CLIENT_ID!, + process.env.COMMS_CLIENT_ID!, scopes, state, 'http://localhost:3000/auth/callback', @@ -276,18 +276,18 @@ app.get('/auth/callback', async (req, res) => { try { const tokenResponse = await getAuthToken({ - clientId: process.env.TWIST_CLIENT_ID!, - clientSecret: process.env.TWIST_CLIENT_SECRET!, + clientId: process.env.COMMS_CLIENT_ID!, + clientSecret: process.env.COMMS_CLIENT_SECRET!, code, redirectUri: 'http://localhost:3000/auth/callback', }) req.session.accessToken = tokenResponse.accessToken - const api = new TwistApi(tokenResponse.accessToken) + const api = new CommsApi(tokenResponse.accessToken) const user = await api.users.getSessionUser() - res.send(`Successfully authenticated as ${user.name}!`) + res.send(`Successfully authenticated as ${user.fullName}!`) } catch (error) { res.status(500).send('Authentication failed') } @@ -310,14 +310,13 @@ The SDK exports these types for OAuth operations: ```typescript import type { - TwistScope, + CommsScope, AuthTokenRequestArgs, AuthTokenResponse, RevokeAuthTokenRequestArgs, -} from '@doist/twist-sdk' +} from '@doist/comms-sdk' ``` ## Additional Resources -- [Twist API Documentation](https://developer.twist.com/v3/#authentication) - [OAuth 2.0 Authorization Code Flow](https://oauth.net/2/grant-types/authorization-code/) diff --git a/website/docs/index.md b/website/docs/index.md index faa3f33..51b0c36 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -4,36 +4,36 @@ title: Getting Started sidebar_position: 1 --- -# Twist SDK TypeScript +# Comms SDK TypeScript -The official TypeScript SDK for the Twist REST API. +The official TypeScript SDK for the Comms REST API. ## Installation ```bash -npm install @doist/twist-sdk +npm install @doist/comms-sdk ``` ## Quick Start ```typescript -import { TwistApi } from '@doist/twist-sdk' +import { CommsApi } from '@doist/comms-sdk' -const api = new TwistApi('your-api-token') +const api = new CommsApi('your-api-token') // Get the current user const user = await api.users.getSessionUser() -console.log(user.name) +console.log(user.fullName) // Get all workspaces const workspaces = await api.workspaces.getWorkspaces() // Get channels in a workspace -const channels = await api.channels.getChannels({ workspaceId: 123 }) +const channels = await api.channels.getChannels({ workspaceId: 1 }) -// Create a new thread +// Create a new thread (channelId comes from the channel you fetched) const thread = await api.threads.createThread({ - channelId: 456, + channelId: channels[0].id, content: 'Hello from the SDK!', title: 'My First Thread', }) @@ -41,4 +41,4 @@ const thread = await api.threads.createThread({ ## API Reference -See the [API Reference](./api/classes/TwistApi.md) for detailed documentation of all available methods and types. +See the [API Reference](./api/classes/CommsApi.md) for detailed documentation of all available methods and types. diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index a0b4122..245e834 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -5,17 +5,17 @@ import { themes as prismThemes } from 'prism-react-renderer' // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) const config: Config = { - title: 'Twist SDK TypeScript', - tagline: 'The TypeScript SDK for the Twist REST API.', + title: 'Comms SDK TypeScript', + tagline: 'The TypeScript SDK for the Comms REST API.', favicon: 'img/favicon.ico', url: 'https://doist.github.io/', // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' - baseUrl: '/twist-sdk-typescript/', + baseUrl: '/comms-sdk-typescript/', organizationName: 'Doist', - projectName: 'twist-sdk-typescript', + projectName: 'comms-sdk-typescript', onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', @@ -42,12 +42,12 @@ const config: Config = { ], themeConfig: { - image: 'img/twist-social-card.png', + image: 'img/todoist-social-card.png', navbar: { - title: 'Twist SDK TypeScript', + title: 'Comms SDK TypeScript', logo: { - alt: 'Twist Logo', - src: 'img/twist-logo.png', + alt: 'Comms Logo', + src: 'img/todoist-logo.svg', }, items: [ { @@ -56,7 +56,7 @@ const config: Config = { to: '/', }, { - href: 'https://github.com/Doist/twist-sdk-typescript', + href: 'https://github.com/Doist/comms-sdk-typescript', label: 'GitHub', position: 'right', }, @@ -78,7 +78,7 @@ const config: Config = { }, { label: 'API Reference', - to: '/api/classes/TwistApi', + to: '/api/classes/CommsApi', }, ], }, @@ -91,7 +91,7 @@ const config: Config = { }, { label: 'GitHub', - href: 'https://github.com/Doist/twist-sdk-typescript', + href: 'https://github.com/Doist/comms-sdk-typescript', }, ], }, diff --git a/website/static/img/twist-logo.png b/website/static/img/twist-logo.png deleted file mode 100644 index fbb58afa69ea4391e1e9c68dfd1a58af6863fffb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3892 zcmV-456ke0P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGf6951U69E94oEQKA4#`PGK~#8N?VWjW z6m=HIU-$GJIY@v2kt+nb#>7Om4o}UwNZW8 zatp^?VHn1xeB~GT?A%2FSV$l@sWMa$VPs8{F!{#{&-~{O=l)Ut7DemiElk)yX$1F0 z%s|sYB7|yEB%od8+O9|f=+^3y(6$co$@3MS4fFTsBq?PSo$<{xiMysfYRMcJ!JH%? z?L-8ETyqC(?yT~R3XxiVU9&K!;F9OnxFfkKQVm77?0P(*c=2S*;b8;VTw>fa-NOP3 zfFpNa{s@t^c45JxbFQ^(3iGM)gNTZCsRc0uO#fmS*iS^zp%eqQexLC}>RN=kht9kH zK#d=K#O}zNQ{O85O%<rO`{UN^-!Fm_PPpiF@^$g8VqSiV|!bDt0~A>J&DR$I-sf zFUNlotQT&0pLpR&-dgE1B`A5ZAV1{NO@4`o2Nc0TO#WF^LT$4!qOjDxSpFI%n39U?eABCs89(W{hE48*45d`pLu2*BwU-AWn7V7R?R0D=e(vo-&lCefJ%GVs1!Y|z#h9=n=>|}Y3)?T8b^?qH zJ9Cs7@)stwDW53@gQ;xb-?ff>_8(&8U`@ruq?UA1FIZh;UsxO3-J)w z>IB$8y+S~d1hD&i$Idl{`E*mL0YL$<*pH_e7gnIt!G?x4a{=i4QFH^_3pt-I**Y~I z(&yq9`rwB7M^4+$H?Qn7WD>Y-`bvllWg#+*fe5^ZFc41ep%wtEg#pgQKz%b0L$_rq zeJg6o8zP)8^TN_YIZ93MqA3AjJnkbImbhxgO^1;dZ8bAs=48X-9gS-yt{Yo)Ai%{h z_I5#;^SJ7y|C79}By=sD zXn{wgImvr}o2KhwedZaT#7%z~sJ z0_Wm=Y$tvBYcHol+$<~fCpnMK@cPYUxSpf;e!d89($p*Jc-ULu1m>M|2zx&R=H&No zEGCAWHmVOfW|KKnkv1C4Gq3s68}`Sx!+!1`_mjNFk0@kayS%DZ#$ zsYJNGE)}MXFiQhc4?F0jBCVPg02;ipO@PZ)Uij*)3#|Xnfas4hVeZ~+)z%B*4B=0y zDe&}Ei`43INLlANH6s8w?nNXS`;B@*8Xnz$$^}s$WWqR-wLgD2SNWED99fnO@$+mj zaRdjJ;8{b#t118-32P#)yo|K+6D4l=qR<5~pJc+UygiVlk-B)f%gGCd~FhGAv z-fyCs0MNx>sg_&%7}ClYf0?7HDc7BkCqT)Isqp+vD-4O!O{#R?sPfCOzg||&OM^0N zPit@K_o;+rNStSdaYIc~_coYOMF6_(@31>AgT|m>imTk zQ0M2XV1Mk-m%Wq<^Tu0XNPiYATFCB+BUF21R?zEz$BcM5pOON2_O z7+>vtD5wQ+g2egwxi**_%Sp@9w1&ea!1X%cySvo^1)zRL=4TEqO@#W_(qJy>*5N(@ zErLtbN~;CchrCe$e%T)`O^M#)|8<~DlD!~78pv6K>k>(Vr&ZAS=dXETX0AG`fji?j z4&-X$@tA67@Vt4`g#uM%BBAmG7xqYO}`3i53)7zc# z3i6Dno-w=t@I{z-SYyA<8L+t1#LcX{Z1}p!MO^&dTR5(7c)?Fo+iGEG0pK}-2hX_f zdQvBzJ~CiSCY-DA^cWJ*Yv>&mfOaR;1BBBh!l_a`HZGI2;NM=IGi^^cd|T}967SJ- z)Hdlm{v(F-0C;@d0n+Mc-{*l+E56Hxyc13+uaRRu9!`Ce{9Ss-Fdm@Afk)4HAm)>- z2OhS4<=kEv^=TFqmb&3ejTh1aaJ+196Z9Sc4~@jl7vB&Uj)|Q=n3JCaqd(Q()NfCq z0Q8^9^Z*X#;JLs>lmi>hzojP7~E5x)lI2GdCL+9-!|2VbHYzC=U-6fUyy% z0E~^gr-f5g$3+ETY&<9c{m*Gq9u(=VVL>qxNO>@52rxKQ00xJK0AnM)I5Mc#15oY* z5t>*Xzx0su(1=wmXQCd!sBk8xh7GeZF6A@DKzuN1Wsc2)0IDHg%@CARU`lUi4u28+KK!~xt5|gw$F%PF?XE#Xy~e$ ztv_C^hq$ULrREL{8lBL^Es!!3xlOdU3x6)J=ASOQ>KU}DcyEYYOVK*-jbHhn=`rG# zF>ODWIsU%RJL=e-nD2wwSXv8$0zhL6cM-v8ay`-5Dom|zo8`ta`!=x@Eado&}gSF-)iV529uP;xm?029c=b?L(|MSv>7%S^5?FE2*4 zI>eAxr)YJ1FjR=FS3oZ!)mxSCampPpq)V8gJP`PQhz%0&xP;G3o&Q^Bnf#B3 zS{X4Slxeq`nM2>w08$wlhn#{U7axH6PY|J|4X0000 Date: Wed, 20 May 2026 16:31:04 +0200 Subject: [PATCH 2/4] review: address Doistbot findings on PR #1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied all 12 P2 review comments: - Export ChannelListSchema / CommentListSchema / ConversationListSchema / ConversationMessageListSchema / GroupListSchema / ThreadListSchema, plus the workspaces.getPublicChannels list schema. Matches the project convention in AGENTS.md ("all Zod schemas should be exported"). - Tighten StatusOkSchema to `z.object({ status: z.literal('ok') })` so a backend regression surfaces as a parse error instead of being silently typed away. - README: keep the UUIDv7/base58 requirement explicit on caller-supplied ids so the documented contract matches resolveCreateId's validation. - Move EVERYONE / EVERYONE_IN_THREAD / GROUP_ID_MARKERS / GroupId out of ./entities and into ./enums to break a circular import: ./entities imports ./enums for USER_TYPES/WORKSPACE_PLANS, and ./enums was importing ./entities for the marker constants. At init time EVERYONE was undefined when NOTIFY_AUDIENCE_GROUP_IDS / SENTINEL_GROUP_IDS were built, so the marker-rejection guard was dead. Caught by the new add-comment-helper tests. - Add focused regression tests: - src/clients/add-comment-helper.test.ts — markers in groups, markers in directGroupMentions, both fields at once, and the notifyAudience translation. - src/clients/comments-client.test.ts — pins thread_id / newer_than_ts / older_than_ts / comment_id on the wire. - src/clients/conversation-messages-client.test.ts — pins conversation_id / newer_than_ts / older_than_ts / direct_mentions / direct_group_mentions on the wire. Test count: 104 -> 115. Type-check, lint, and tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 +- src/clients/add-comment-helper.test.ts | 69 ++++++++++++ src/clients/channels-client.ts | 2 +- src/clients/comments-client.test.ts | 85 ++++++++++++++ src/clients/comments-client.ts | 2 +- .../conversation-messages-client.test.ts | 104 ++++++++++++++++++ src/clients/conversation-messages-client.ts | 2 +- src/clients/conversations-client.ts | 2 +- src/clients/groups-client.ts | 2 +- src/clients/threads-client.ts | 2 +- src/clients/workspaces-client.ts | 2 +- src/types/entities.ts | 33 ++---- src/types/enums.ts | 22 +++- 13 files changed, 304 insertions(+), 35 deletions(-) create mode 100644 src/clients/add-comment-helper.test.ts create mode 100644 src/clients/comments-client.test.ts create mode 100644 src/clients/conversation-messages-client.test.ts diff --git a/README.md b/README.md index 9016223..3a24efe 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ const api = new CommsApi('YOUR_API_TOKEN', { ### Creating entities Channel / thread / comment / conversation / message / group IDs are -opaque strings; `workspaceId` and `userId` are numeric. +opaque base58-encoded UUIDv7 strings; `workspaceId` and `userId` are +numeric. Creation endpoints (`createChannel`, `createThread`, `createComment`, `getOrCreateConversation`, `createMessage`, `createGroup`) accept an -optional `id`. Pass your own to keep an optimistic-UI ID stable through -the round-trip, or let the SDK mint one with `generateId()`: +optional `id`. **A caller-supplied `id` must be a base58-encoded +UUIDv7** — anything else fails fast with a `UuidV7Error` before the +request leaves the SDK. Either mint your own with `generateId()` (handy +for optimistic UI — the ID survives the round-trip unchanged) or omit +`id` and let the SDK mint one: ```typescript import { CommsApi, generateId } from '@doist/comms-sdk' @@ -52,7 +56,7 @@ const channel = await api.channels.createChannel({ name: 'Engineering', }) -// Option 2: mint the ID yourself +// Option 2: mint the ID yourself (must be a base58 UUIDv7 from generateId) const id = generateId() const sameChannel = await api.channels.createChannel({ workspaceId: 1, diff --git a/src/clients/add-comment-helper.test.ts b/src/clients/add-comment-helper.test.ts new file mode 100644 index 0000000..b3cca28 --- /dev/null +++ b/src/clients/add-comment-helper.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { 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: 'https://comms.todoist.com/api/v3/', apiToken: TEST_API_TOKEN } + +describe('addCommentRequest — reserved broadcast marker validation', () => { + it('throws when a marker is passed in `groups`', () => { + expect(() => + addCommentRequest(ctx, { + threadId: TEST_THREAD_ID, + content: 'hello', + groups: [EVERYONE], + }), + ).toThrow(/`groups` contains EVERYONE/) + }) + + it('throws when a marker is passed in `directGroupMentions`', () => { + expect(() => + addCommentRequest(ctx, { + threadId: TEST_THREAD_ID, + content: 'hello', + directGroupMentions: [EVERYONE_IN_THREAD], + }), + ).toThrow(/`directGroupMentions` contains EVERYONE_IN_THREAD/) + }) + + it('reports both fields in one error when markers appear in both', () => { + let caught: Error | null = null + try { + addCommentRequest(ctx, { + threadId: TEST_THREAD_ID, + content: 'hello', + groups: [EVERYONE], + directGroupMentions: [EVERYONE_IN_THREAD], + }) + } catch (e) { + caught = e as Error + } + expect(caught).not.toBeNull() + expect(caught?.message).toMatch(/`groups` contains EVERYONE/) + expect(caught?.message).toMatch(/`directGroupMentions` contains EVERYONE_IN_THREAD/) + expect(caught?.message).toMatch(/notifyAudience/) + }) + + it('translates notifyAudience: channel into the EVERYONE marker', () => { + const descriptor = addCommentRequest( + ctx, + { threadId: TEST_THREAD_ID, content: 'hello', notifyAudience: 'channel' }, + { batch: true }, + ) + expect('params' in descriptor).toBe(true) + if (!('params' in descriptor)) return + const groups = (descriptor.params as Record)?.groups as string[] + expect(groups).toEqual([EVERYONE]) + }) + + it('translates notifyAudience: thread into the EVERYONE_IN_THREAD marker', () => { + const descriptor = addCommentRequest( + ctx, + { threadId: TEST_THREAD_ID, content: 'hello', notifyAudience: 'thread' }, + { batch: true }, + ) + if (!('params' in descriptor)) return + const groups = (descriptor.params as Record)?.groups as string[] + expect(groups).toEqual([EVERYONE_IN_THREAD]) + }) +}) diff --git a/src/clients/channels-client.ts b/src/clients/channels-client.ts index 5f73e38..262bec2 100644 --- a/src/clients/channels-client.ts +++ b/src/clients/channels-client.ts @@ -15,7 +15,7 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const ChannelListSchema = z.array(ChannelSchema) +export const ChannelListSchema = z.array(ChannelSchema) /** * Client for `/api/v3/channels/`. The SDK auto-generates an `id` on diff --git a/src/clients/comments-client.test.ts b/src/clients/comments-client.test.ts new file mode 100644 index 0000000..5113b3d --- /dev/null +++ b/src/clients/comments-client.test.ts @@ -0,0 +1,85 @@ +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/v3' + +// These tests pin the wire shape of `comments-client` — every camelCase +// field on the args side ends up snake_case on the wire (via +// `snakeCaseKeys` in the transport layer). The simplify pass dropped +// hand-rolled snake-casing inside the client, so this is the only thing +// that catches a casing regression locally. + +describe('CommentsClient — wire serialization', () => { + it('getComments sends thread_id / newer_than_ts / older_than_ts on the URL', async () => { + let capturedUrl: URL | null = null + server.use( + http.get(`${BASE}/comments/get`, ({ request }) => { + capturedUrl = new URL(request.url) + return HttpResponse.json([]) + }), + ) + + const api = new CommsApi(TEST_API_TOKEN) + await api.comments.getComments({ + threadId: TEST_THREAD_ID, + newerThan: new Date('2026-01-01T00:00:00Z'), + olderThan: new Date('2026-02-01T00:00:00Z'), + limit: 50, + }) + + if (capturedUrl === null) throw new Error('expected a captured URL') + const params = capturedUrl.searchParams + expect(params.get('thread_id')).toBe(TEST_THREAD_ID) + expect(params.get('newer_than_ts')).toBe( + String(Math.floor(new Date('2026-01-01T00:00:00Z').getTime() / 1000)), + ) + expect(params.get('older_than_ts')).toBe( + String(Math.floor(new Date('2026-02-01T00:00:00Z').getTime() / 1000)), + ) + expect(params.get('limit')).toBe('50') + }) + + it('markPosition POSTs thread_id and comment_id as snake_case', async () => { + let capturedBody: Record | null = null + server.use( + http.post(`${BASE}/comments/mark_position`, async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json({ status: 'ok' }) + }), + ) + + const api = new CommsApi(TEST_API_TOKEN) + await api.comments.markPosition({ + threadId: TEST_THREAD_ID, + commentId: TEST_COMMENT_ID, + }) + + expect(capturedBody).toEqual({ + thread_id: TEST_THREAD_ID, + comment_id: TEST_COMMENT_ID, + }) + }) + + it('batch descriptors carry camelCase params (transport snake-cases on send)', () => { + const api = new CommsApi(TEST_API_TOKEN) + const descriptor = api.comments.getComments( + { + threadId: TEST_THREAD_ID, + newerThan: new Date('2026-01-01T00:00:00Z'), + limit: 10, + }, + { batch: true }, + ) + if (!('params' in descriptor) || !descriptor.params) { + throw new Error('expected batch descriptor with params') + } + expect(descriptor.params).toMatchObject({ + threadId: TEST_THREAD_ID, + limit: 10, + }) + expect(descriptor.params.newerThanTs).toBeTypeOf('number') + }) +}) diff --git a/src/clients/comments-client.ts b/src/clients/comments-client.ts index 5705edd..743a5ea 100644 --- a/src/clients/comments-client.ts +++ b/src/clients/comments-client.ts @@ -12,7 +12,7 @@ import type { import { addCommentRequest } from './add-comment-helper' import { BaseClient } from './base-client' -const CommentListSchema = z.array(CommentSchema) +export const CommentListSchema = z.array(CommentSchema) /** * Client for `/api/v3/comments/`. The SDK auto-generates the comment `id` diff --git a/src/clients/conversation-messages-client.test.ts b/src/clients/conversation-messages-client.test.ts new file mode 100644 index 0000000..5c6c1c6 --- /dev/null +++ b/src/clients/conversation-messages-client.test.ts @@ -0,0 +1,104 @@ +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_CONVERSATION_ID } from '../testUtils/test-defaults' +import { generateId } from '../utils/uuidv7' + +const BASE = 'https://comms.todoist.com/api/v3' + +// The transport layer parses JSON, camelCases keys, and turns `*_ts` +// epoch seconds into Date fields. Wire fixtures here use the raw +// snake_case + `_ts` shape so the response goes through that pipeline +// like a real API response would. +const MESSAGE_RESPONSE = { + id: 1, + content: 'hi', + creator: 1, + conversation_id: TEST_CONVERSATION_ID, + posted_ts: Math.floor(Date.now() / 1000), + workspace_id: 1, + obj_index: 0, +} + +// Pins wire shape for conversation-messages: camelCase args → snake_case +// payload (via transport's `snakeCaseKeys`). The simplify pass removed +// hand-rolled snake-casing in this client; without these tests, a casing +// regression only surfaces against the real API. + +describe('ConversationMessagesClient — wire serialization', () => { + it('getMessages sends conversation_id / newer_than_ts / older_than_ts', async () => { + let capturedUrl: URL | null = null + server.use( + http.get(`${BASE}/conversation_messages/get`, ({ request }) => { + capturedUrl = new URL(request.url) + return HttpResponse.json([]) + }), + ) + + const api = new CommsApi(TEST_API_TOKEN) + await api.conversationMessages.getMessages({ + conversationId: TEST_CONVERSATION_ID, + newerThan: new Date('2026-01-01T00:00:00Z'), + olderThan: new Date('2026-02-01T00:00:00Z'), + limit: 50, + cursor: 'abc', + }) + + if (capturedUrl === null) throw new Error('expected a captured URL') + const params = capturedUrl.searchParams + expect(params.get('conversation_id')).toBe(TEST_CONVERSATION_ID) + expect(params.get('newer_than_ts')).toBe( + String(Math.floor(new Date('2026-01-01T00:00:00Z').getTime() / 1000)), + ) + expect(params.get('older_than_ts')).toBe( + String(Math.floor(new Date('2026-02-01T00:00:00Z').getTime() / 1000)), + ) + expect(params.get('limit')).toBe('50') + expect(params.get('cursor')).toBe('abc') + }) + + it('createMessage POSTs snake_case keys on the wire', async () => { + let capturedBody: Record | null = null + server.use( + http.post(`${BASE}/conversation_messages/add`, async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(MESSAGE_RESPONSE) + }), + ) + + const api = new CommsApi(TEST_API_TOKEN) + await api.conversationMessages.createMessage({ + conversationId: TEST_CONVERSATION_ID, + content: 'hi', + id: generateId(), + directMentions: [42], + directGroupMentions: ['EVERYONE'], + notify: false, + }) + + expect(capturedBody).toMatchObject({ + conversation_id: TEST_CONVERSATION_ID, + content: 'hi', + direct_mentions: [42], + direct_group_mentions: ['EVERYONE'], + notify: false, + }) + expect(capturedBody?.id).toBeTypeOf('string') + }) + + it('batch descriptor carries camelCase params', () => { + const api = new CommsApi(TEST_API_TOKEN) + const descriptor = api.conversationMessages.getMessages( + { conversationId: TEST_CONVERSATION_ID, limit: 10 }, + { batch: true }, + ) + if (!('params' in descriptor) || !descriptor.params) { + throw new Error('expected batch descriptor with params') + } + expect(descriptor.params).toMatchObject({ + conversationId: TEST_CONVERSATION_ID, + limit: 10, + }) + }) +}) diff --git a/src/clients/conversation-messages-client.ts b/src/clients/conversation-messages-client.ts index cd52cfc..c6ea8e7 100644 --- a/src/clients/conversation-messages-client.ts +++ b/src/clients/conversation-messages-client.ts @@ -16,7 +16,7 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const ConversationMessageListSchema = z.array(ConversationMessageSchema) +export const ConversationMessageListSchema = z.array(ConversationMessageSchema) /** * Client for `/api/v3/conversation_messages/`. The SDK auto-generates the diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index 3b7eb5f..49c76c8 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -23,7 +23,7 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const ConversationListSchema = z.array(ConversationSchema) +export const ConversationListSchema = z.array(ConversationSchema) const GetUnreadResponseSchema = z.object({ data: z.array(UnreadConversationSchema), diff --git a/src/clients/groups-client.ts b/src/clients/groups-client.ts index b4da3ce..2d236a8 100644 --- a/src/clients/groups-client.ts +++ b/src/clients/groups-client.ts @@ -12,7 +12,7 @@ import type { import { resolveCreateId } from '../utils/uuidv7' import { BaseClient } from './base-client' -const GroupListSchema = z.array(GroupSchema) +export const GroupListSchema = z.array(GroupSchema) /** * Client for `/api/v3/groups/`. The broadcast markers `EVERYONE` / diff --git a/src/clients/threads-client.ts b/src/clients/threads-client.ts index c06faf0..24756fc 100644 --- a/src/clients/threads-client.ts +++ b/src/clients/threads-client.ts @@ -28,7 +28,7 @@ import { resolveCreateId } from '../utils/uuidv7' import { addCommentRequest } from './add-comment-helper' import { BaseClient } from './base-client' -const ThreadListSchema = z.array(ThreadSchema) +export const ThreadListSchema = z.array(ThreadSchema) const GetUnreadResponseSchema = z.object({ data: z.array(UnreadThreadSchema), diff --git a/src/clients/workspaces-client.ts b/src/clients/workspaces-client.ts index 1407285..be17fe9 100644 --- a/src/clients/workspaces-client.ts +++ b/src/clients/workspaces-client.ts @@ -5,7 +5,7 @@ import type { BatchRequestDescriptor } from '../types/batch' import { Channel, ChannelSchema, Workspace, WorkspaceSchema } from '../types/entities' import { BaseClient } from './base-client' -const ChannelListSchema = z.array(ChannelSchema) +export const ChannelListSchema = z.array(ChannelSchema) /** * Client for `/api/v3/workspaces/`. Workspace IDs are integers. The backend diff --git a/src/types/entities.ts b/src/types/entities.ts index 34744dc..cc9faa8 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -2,25 +2,10 @@ import { z } from 'zod' import { getFullCommsURL } from '../utils/url-helpers' import { USER_TYPES, WORKSPACE_PLANS } from './enums' -/** - * Marker constants for the two "broadcast" group recipients. These appear in - * group-bearing fields (`Thread.groups`, `Comment.groups`, - * `Channel.defaultGroups`, `directGroupMentions`, etc.) in place of a real - * group ID and tell the backend to notify "everyone in the channel" or - * "everyone in the thread" respectively. Input and output are symmetric. - */ -export const EVERYONE = 'EVERYONE' as const -export const EVERYONE_IN_THREAD = 'EVERYONE_IN_THREAD' as const - -/** Union of the two broadcast group markers. */ -export const GROUP_ID_MARKERS = [EVERYONE, EVERYONE_IN_THREAD] as const -export type GroupIdMarker = (typeof GROUP_ID_MARKERS)[number] - -/** - * A group identifier on the wire — either an opaque group ID or one of the - * {@link GROUP_ID_MARKERS} for a broadcast audience. - */ -export type GroupId = string +// EVERYONE / EVERYONE_IN_THREAD / GROUP_ID_MARKERS / GroupId / GroupIdMarker +// are defined in `./enums` (re-exported through `./index`) to keep this +// module from depending back on `./enums` for them. They're available +// from the same public surface (`@doist/comms-sdk`) either way. // Reusable schema for system messages that can be either a string or an // object. Nullable — the backend returns `null` when there is no system @@ -28,11 +13,13 @@ export type GroupId = string export const SystemMessageSchema = z.union([z.string(), z.unknown()]).nullable().optional() /** - * Shared `{ status: "ok" }` response shape. Most write endpoints that - * don't return an entity use this — archive / unarchive / mark-read / - * mark-all-read / mute / clear-unread / etc. + * Shared `{ status: "ok" }` response shape. Pinned to the literal `'ok'` + * so a regression on the backend (e.g. a status code change) surfaces as + * a parse error here instead of being silently typed away. Most write + * endpoints that don't return an entity use this — archive / unarchive / + * mark-read / mark-all-read / mute / clear-unread / etc. */ -export const StatusOkSchema = z.object({ status: z.string() }) +export const StatusOkSchema = z.object({ status: z.literal('ok') }) export type StatusOk = z.infer // Attachment entity from API. Mirrors the canonical backend shape produced diff --git a/src/types/enums.ts b/src/types/enums.ts index 0d96ca8..000a317 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -1,4 +1,24 @@ -import { EVERYONE, EVERYONE_IN_THREAD, type GroupId } from './entities' +/** + * Marker constants for the two "broadcast" group recipients. These appear + * in group-bearing fields (`Thread.groups`, `Comment.groups`, + * `Channel.defaultGroups`, `directGroupMentions`, etc.) in place of a + * real group ID and tell the backend to notify "everyone in the channel" + * or "everyone in the thread" respectively. Input and output are + * symmetric. Defined here (and not in `./entities`) so that `enums` + * stays leaf-level — `entities` imports from `enums`, never the reverse. + */ +export const EVERYONE = 'EVERYONE' as const +export const EVERYONE_IN_THREAD = 'EVERYONE_IN_THREAD' as const + +/** Union of the two broadcast group markers. */ +export const GROUP_ID_MARKERS = [EVERYONE, EVERYONE_IN_THREAD] as const +export type GroupIdMarker = (typeof GROUP_ID_MARKERS)[number] + +/** + * A group identifier on the wire — either an opaque group ID or one of + * the {@link GROUP_ID_MARKERS} for a broadcast audience. + */ +export type GroupId = string // User types for workspace users export const USER_TYPES = ['USER', 'GUEST', 'ADMIN'] as const From 0124291944d5135febaf427d7d1c933cb1d162aa Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 18:40:41 +0200 Subject: [PATCH 3/4] fix(test): use array capture so CI tsc emit succeeds The CI build (tsconfig.cjs.json) emits, not just type-checks, and was narrowing `let capturedUrl: URL | null = null` to `never` after the null guard because the only writes happen inside an MSW closure that TS doesn't see. Locally `tsc --noEmit` didn't trip on the same code. Switch the captured-request pattern to a `URL[]` / `Record[]` and pop the first entry. Avoids the narrowing dead end and the test reads cleaner. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients/comments-client.test.ts | 8 ++++---- .../conversation-messages-client.test.ts | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/clients/comments-client.test.ts b/src/clients/comments-client.test.ts index 5113b3d..5240065 100644 --- a/src/clients/comments-client.test.ts +++ b/src/clients/comments-client.test.ts @@ -14,10 +14,10 @@ const BASE = 'https://comms.todoist.com/api/v3' describe('CommentsClient — wire serialization', () => { it('getComments sends thread_id / newer_than_ts / older_than_ts on the URL', async () => { - let capturedUrl: URL | null = null + const capturedUrls: URL[] = [] server.use( http.get(`${BASE}/comments/get`, ({ request }) => { - capturedUrl = new URL(request.url) + capturedUrls.push(new URL(request.url)) return HttpResponse.json([]) }), ) @@ -30,8 +30,8 @@ describe('CommentsClient — wire serialization', () => { limit: 50, }) - if (capturedUrl === null) throw new Error('expected a captured URL') - const params = capturedUrl.searchParams + expect(capturedUrls).toHaveLength(1) + const params = (capturedUrls[0] as URL).searchParams expect(params.get('thread_id')).toBe(TEST_THREAD_ID) expect(params.get('newer_than_ts')).toBe( String(Math.floor(new Date('2026-01-01T00:00:00Z').getTime() / 1000)), diff --git a/src/clients/conversation-messages-client.test.ts b/src/clients/conversation-messages-client.test.ts index 5c6c1c6..f2dfd2b 100644 --- a/src/clients/conversation-messages-client.test.ts +++ b/src/clients/conversation-messages-client.test.ts @@ -28,10 +28,10 @@ const MESSAGE_RESPONSE = { describe('ConversationMessagesClient — wire serialization', () => { it('getMessages sends conversation_id / newer_than_ts / older_than_ts', async () => { - let capturedUrl: URL | null = null + const capturedUrls: URL[] = [] server.use( http.get(`${BASE}/conversation_messages/get`, ({ request }) => { - capturedUrl = new URL(request.url) + capturedUrls.push(new URL(request.url)) return HttpResponse.json([]) }), ) @@ -45,8 +45,8 @@ describe('ConversationMessagesClient — wire serialization', () => { cursor: 'abc', }) - if (capturedUrl === null) throw new Error('expected a captured URL') - const params = capturedUrl.searchParams + expect(capturedUrls).toHaveLength(1) + const params = (capturedUrls[0] as URL).searchParams expect(params.get('conversation_id')).toBe(TEST_CONVERSATION_ID) expect(params.get('newer_than_ts')).toBe( String(Math.floor(new Date('2026-01-01T00:00:00Z').getTime() / 1000)), @@ -59,10 +59,10 @@ describe('ConversationMessagesClient — wire serialization', () => { }) it('createMessage POSTs snake_case keys on the wire', async () => { - let capturedBody: Record | null = null + const capturedBodies: Record[] = [] server.use( http.post(`${BASE}/conversation_messages/add`, async ({ request }) => { - capturedBody = (await request.json()) as Record + capturedBodies.push((await request.json()) as Record) return HttpResponse.json(MESSAGE_RESPONSE) }), ) @@ -77,14 +77,16 @@ describe('ConversationMessagesClient — wire serialization', () => { notify: false, }) - expect(capturedBody).toMatchObject({ + expect(capturedBodies).toHaveLength(1) + const body = capturedBodies[0] as Record + expect(body).toMatchObject({ conversation_id: TEST_CONVERSATION_ID, content: 'hi', direct_mentions: [42], direct_group_mentions: ['EVERYONE'], notify: false, }) - expect(capturedBody?.id).toBeTypeOf('string') + expect(body.id).toBeTypeOf('string') }) it('batch descriptor carries camelCase params', () => { From 88c5283b946841cb06719264cc7c8a38426438c4 Mon Sep 17 00:00:00 2001 From: Amir Date: Wed, 20 May 2026 18:45:35 +0200 Subject: [PATCH 4/4] fix: schema mismatches found smoke-testing against staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-tested the SDK against comms.staging.todoist.com / workspace 48121 and found three real schema-vs-reality mismatches: 1. Workspace.plan: the WORKSPACE_PLANS enum (`free`|`unlimited`) was incomplete — staging returns `business`. Widened to `z.string()` and added `business` to the const array. Plans evolve faster than SDK releases so the runtime check was the wrong shape. 2. Workspace.imageId / defaultConversation: I had renamed `avatar_id` → `imageId` on Workspace, but the `name → fullName` / `avatar_id → imageId` rename in `Comms_API_changes.md` PR #125 applies to User only. Reverted Workspace back to `avatarId`. Also `defaultConversation` is a base58 UUIDv7 string on the wire (it was `z.number()`). 3. Thread.pinned: the wire shape only includes `pinned_ts` (epoch ms or null); the legacy `pinned` bool that the change-doc claimed would stick around for Zapier compatibility isn't actually present. Loosened to `z.boolean().optional()` — callers should derive from `pinnedTs != null` if they need a bool. Also removed the unused `WORKSPACE_PLANS` import from entities.ts (was flagged by oxlint). Verified by re-running the smoke test against staging: getSessionUser, getWorkspaces, getWorkspace, getWorkspaceUsers, getChannels, getInbox, getThreads, getGroups, getConversations, createChannel + archiveChannel + deleteChannel all return 2xx and parse cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 ++- src/types/entities.ts | 20 ++++++++++++++------ src/types/enums.ts | 17 ++++++++--------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 40abc54..eab31ec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ dist *.tgz .claude/settings.local.json scratch.ts -.vscode/launch.json \ No newline at end of file +scratch-*.ts +.vscode/launch.json diff --git a/src/types/entities.ts b/src/types/entities.ts index cc9faa8..ed96164 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { getFullCommsURL } from '../utils/url-helpers' -import { USER_TYPES, WORKSPACE_PLANS } from './enums' +import { USER_TYPES } from './enums' // EVERYONE / EVERYONE_IN_THREAD / GROUP_ID_MARKERS / GroupId / GroupIdMarker // are defined in `./enums` (re-exported through `./index`) to keep this @@ -87,13 +87,17 @@ export const UserSchema = BaseUserSchema.extend({ export type User = z.infer +// Workspace entity from API. `default_conversation` is a base58 UUIDv7 +// string. `avatar_id` is kept (unlike the User rename to `image_id`, +// which is a User-only change per Comms_API_changes.md PR #125). +// `plan` is intentionally `z.string()` — see {@link WORKSPACE_PLANS}. export const WorkspaceSchema = z.object({ id: z.number(), name: z.string(), - defaultConversation: z.number().nullable().optional(), + defaultConversation: z.string().nullable().optional(), creator: z.number(), created: z.date(), - imageId: z.string().nullable().optional(), + avatarId: z.string().nullable().optional(), avatarUrls: z .object({ s35: z.string(), @@ -103,7 +107,7 @@ export const WorkspaceSchema = z.object({ }) .nullable() .optional(), - plan: z.enum(WORKSPACE_PLANS).nullable().optional(), + plan: z.string().nullable().optional(), }) export type Workspace = z.infer @@ -158,7 +162,9 @@ export const ThreadSchema = z lastUpdated: z.date(), mutedUntil: z.date().nullable().optional(), participants: z.array(z.number()).nullable().optional(), - pinned: z.boolean(), + // Backend wire shape only includes `pinned_ts` (epoch ms or null); + // derive `pinned` from `pinnedTs != null` if you need a bool. + pinned: z.boolean().optional(), pinnedTs: z.number().int().nullable().optional(), posted: z.date(), reactions: z.record(z.string(), z.unknown()).nullable().optional(), @@ -377,7 +383,9 @@ export const InboxThreadSchema = z lastUpdated: z.date(), mutedUntil: z.date().nullable().optional(), participants: z.array(z.number()).nullable().optional(), - pinned: z.boolean(), + // Backend wire shape only includes `pinned_ts` (epoch ms or null); + // derive `pinned` from `pinnedTs != null` if you need a bool. + pinned: z.boolean().optional(), pinnedTs: z.number().int().nullable().optional(), posted: z.date(), reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), diff --git a/src/types/enums.ts b/src/types/enums.ts index 000a317..6d7bc52 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -34,18 +34,17 @@ export const USER_TYPES = ['USER', 'GUEST', 'ADMIN'] as const */ export type UserType = (typeof USER_TYPES)[number] -// Workspace plans -export const WORKSPACE_PLANS = ['free', 'unlimited'] as const +// Workspace plans. The known values today are `'free'`, `'unlimited'`, +// and `'business'`, but the backend can introduce new plan names without +// coordinating a SDK release — so the schema accepts any string and the +// const array stays as a hint for autocomplete only. +export const WORKSPACE_PLANS = ['free', 'unlimited', 'business'] as const /** - * The plan type for a workspace. - * - * @remarks - * Possible values: - * - `'free'` - Free plan - * - `'unlimited'` - Unlimited plan + * The plan type for a workspace. Any string accepted; the listed values + * are the ones the backend exposes today. */ -export type WorkspacePlan = (typeof WORKSPACE_PLANS)[number] +export type WorkspacePlan = (typeof WORKSPACE_PLANS)[number] | (string & {}) // Audiences that comment-creating endpoints can target alongside (or instead // of) individual `recipients` / custom `groups`.