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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ dist
*.tgz
.claude/settings.local.json
scratch.ts
.vscode/launch.json
scratch-*.ts
.vscode/launch.json
48 changes: 15 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,19 @@ 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
Comment thread
amix marked this conversation as resolved.
opaque base58-encoded UUIDv7 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`. **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'
Expand All @@ -53,8 +56,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 (must be a base58 UUIDv7 from generateId)
const id = generateId()
const sameChannel = await api.channels.createChannel({
workspaceId: 1,
Expand All @@ -65,11 +67,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({
Expand Down Expand Up @@ -121,25 +122,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`
Expand Down
69 changes: 69 additions & 0 deletions src/clients/add-comment-helper.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)?.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<string, unknown>)?.groups as string[]
expect(groups).toEqual([EVERYONE_IN_THREAD])
})
})
27 changes: 19 additions & 8 deletions src/clients/add-comment-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateCommentArgs, 'notifyAudience'> {
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) {
Comment thread
amix marked this conversation as resolved.
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

Expand Down
18 changes: 7 additions & 11 deletions src/clients/channels-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof StatusOkSchema>
export 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. */
Expand All @@ -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<Channel[]>({
httpMethod: method,
Expand All @@ -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. */
Expand Down
85 changes: 85 additions & 0 deletions src/clients/comments-client.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
const capturedUrls: URL[] = []
server.use(
http.get(`${BASE}/comments/get`, ({ request }) => {
capturedUrls.push(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,
})

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)),
)
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<string, unknown> | null = null
server.use(
http.post(`${BASE}/comments/mark_position`, async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>
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')
})
})
22 changes: 10 additions & 12 deletions src/clients/comments-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof StatusOkSchema>
export 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 {
/**
Expand All @@ -32,18 +30,18 @@ export class CommentsClient extends BaseClient {
args: GetCommentsArgs,
options?: { batch?: boolean },
): Promise<Comment[]> | BatchRequestDescriptor<Comment[]> {
const params: Record<string, unknown> = { thread_id: args.threadId }
const params: Record<string, unknown> = { threadId: args.threadId }
Comment thread
amix marked this conversation as resolved.

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<Comment[]>({
Expand All @@ -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: ...}`. */
Expand Down Expand Up @@ -169,7 +167,7 @@ export class CommentsClient extends BaseClient {
): Promise<StatusOk> | BatchRequestDescriptor<StatusOk> {
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 }
Expand Down
Loading
Loading