From 20a3a447adca263dc4a5a217d2ce2dd6a5dfaf14 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 22 May 2026 16:29:08 +0100 Subject: [PATCH 1/2] fix(entities): honor configured baseUrl in entity links When a consumer constructs `new CommsApi(token, { baseUrl })`, the `.url` field on returned entities (Channel, Thread, Conversation, Comment, ConversationMessage, InboxThread) ignored the configured `baseUrl` and always pointed at the hardcoded `https://comms.todoist.com`. The url-building Zod schemas were module-level singletons, so the per-instance `baseUrl` never reached them. Each entity schema is now built by a `createXxxSchema(linkBaseUrl?)` factory that threads the base into `getFullCommsURL`. The exported `XxxSchema = createXxxSchema()` singletons and inferred types are unchanged (non-breaking). Entity-producing clients hold a per-instance schema built from a new `BaseClient.getLinkBaseUrl()`, which returns the configured `baseUrl` as-is (trailing slash stripped), falling back to the default web app when unset. `InboxThread.lastComment` uses `createCommentSchema(linkBaseUrl)` so the nested link honors the base too. Ported from Doist/twist-sdk-typescript#137. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients/add-comment-helper.test.ts | 3 +- src/clients/add-comment-helper.ts | 6 +- src/clients/base-client.ts | 12 + src/clients/channels-client.ts | 32 +- src/clients/comments-client.test.ts | 61 +++ src/clients/comments-client.ts | 26 +- src/clients/conversation-messages-client.ts | 12 +- src/clients/conversations-client.ts | 28 +- src/clients/inbox-client.ts | 6 +- src/clients/threads-client.ts | 32 +- src/clients/workspaces-client.ts | 12 +- src/types/entities.test.ts | 72 +++- src/types/entities.ts | 445 +++++++++++--------- 13 files changed, 499 insertions(+), 248 deletions(-) diff --git a/src/clients/add-comment-helper.test.ts b/src/clients/add-comment-helper.test.ts index 84cdc5c..b1c6942 100644 --- a/src/clients/add-comment-helper.test.ts +++ b/src/clients/add-comment-helper.test.ts @@ -3,10 +3,11 @@ import { describe, expect, it } from 'vitest' import { getCommsBaseUri } from '../consts/endpoints' import { server } from '../testUtils/msw-setup' import { TEST_API_BASE_URL, TEST_API_TOKEN, TEST_THREAD_ID } from '../testUtils/test-defaults' +import { CommentSchema } from '../types/entities' import { EVERYONE, EVERYONE_IN_THREAD } from '../types/enums' import { addCommentRequest } from './add-comment-helper' -const ctx = { baseUri: getCommsBaseUri(), apiToken: TEST_API_TOKEN } +const ctx = { baseUri: getCommsBaseUri(), apiToken: TEST_API_TOKEN, schema: CommentSchema } const COMMENT_ADD = `${TEST_API_BASE_URL}/comments/add` const COMMENT_RESPONSE = { diff --git a/src/clients/add-comment-helper.ts b/src/clients/add-comment-helper.ts index f85e491..6fe3b38 100644 --- a/src/clients/add-comment-helper.ts +++ b/src/clients/add-comment-helper.ts @@ -1,6 +1,6 @@ import { ENDPOINT_COMMENTS } from '../consts/endpoints' import { request } from '../transport/http-client' -import { type Comment, CommentSchema } from '../types/entities' +import { type Comment, type createCommentSchema } from '../types/entities' import { NOTIFY_AUDIENCE_GROUP_IDS, NOTIFY_AUDIENCES, type NotifyAudience } from '../types/enums' import type { CustomFetch } from '../types/http' import type { CreateCommentArgs, ThreadAction } from '../types/requests' @@ -10,6 +10,8 @@ type ClientContext = { baseUri: string apiToken: string customFetch?: CustomFetch + /** Per-client Comment schema, base-bound for the returned comment's web `url`. */ + schema: ReturnType } const SENTINEL_GROUP_IDS: ReadonlySet = new Set(Object.values(NOTIFY_AUDIENCE_GROUP_IDS)) @@ -90,5 +92,5 @@ export function addCommentRequest( apiToken: context.apiToken, payload, customFetch: context.customFetch, - }).then((response) => CommentSchema.parse(response.data)) + }).then((response) => context.schema.parse(response.data)) } diff --git a/src/clients/base-client.ts b/src/clients/base-client.ts index dbf19c8..7cd7db6 100644 --- a/src/clients/base-client.ts +++ b/src/clients/base-client.ts @@ -38,4 +38,16 @@ export class BaseClient { protected getBaseUri(): string { return getCommsBaseUri(this.defaultVersion, this.baseUrl) } + + /** + * Base URL for entity web links, or `undefined` to use getFullCommsURL's + * default web app. + */ + protected getLinkBaseUrl(): string | undefined { + if (!this.baseUrl) { + return undefined + } + // Strip a trailing slash so links don't double up, since entity paths start with '/'. + return this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl + } } diff --git a/src/clients/channels-client.ts b/src/clients/channels-client.ts index bc56904..dea8830 100644 --- a/src/clients/channels-client.ts +++ b/src/clients/channels-client.ts @@ -1,7 +1,13 @@ import { z } from 'zod' import { ENDPOINT_CHANNELS } from '../consts/endpoints' import { request } from '../transport/http-client' -import { type Channel, ChannelSchema, type StatusOk, StatusOkSchema } from '../types/entities' +import { + type Channel, + ChannelSchema, + createChannelSchema, + type StatusOk, + StatusOkSchema, +} from '../types/entities' import type { AddChannelUserArgs, AddChannelUsersArgs, @@ -22,6 +28,9 @@ export const ChannelListSchema = z.array(ChannelSchema) * to keep an optimistic-UI ID stable through the round-trip. */ export class ChannelsClient extends BaseClient { + private readonly channelSchema = createChannelSchema(this.getLinkBaseUrl()) + private readonly channelListSchema = z.array(this.channelSchema) + /** * Gets all channels for a given workspace. * @@ -44,7 +53,7 @@ export class ChannelsClient extends BaseClient { apiToken: this.apiToken, payload: args, customFetch: this.customFetch, - }).then((response) => ChannelListSchema.parse(response.data)) + }).then((response) => this.channelListSchema.parse(response.data)) } /** @@ -54,7 +63,7 @@ export class ChannelsClient extends BaseClient { * @returns The channel object. */ getChannel(id: string): Promise { - return this.simple('GET', 'getone', { id }, ChannelSchema) + return this.simple('GET', 'getone', { id }, this.channelSchema) } /** @@ -80,7 +89,12 @@ export class ChannelsClient extends BaseClient { * ``` */ createChannel(args: CreateChannelArgs): Promise { - return this.simple('POST', 'add', { ...args, id: resolveCreateId(args.id) }, ChannelSchema) + return this.simple( + 'POST', + 'add', + { ...args, id: resolveCreateId(args.id) }, + this.channelSchema, + ) } /** @@ -95,7 +109,7 @@ export class ChannelsClient extends BaseClient { * @returns The updated channel object. */ updateChannel(args: UpdateChannelArgs): Promise { - return this.simple('POST', 'update', { ...args }, ChannelSchema) + return this.simple('POST', 'update', { ...args }, this.channelSchema) } /** @@ -170,7 +184,7 @@ export class ChannelsClient extends BaseClient { * ``` */ addUser(args: AddChannelUserArgs): Promise { - return this.simple('POST', 'add_user', { ...args }, ChannelSchema) + return this.simple('POST', 'add_user', { ...args }, this.channelSchema) } /** @@ -186,7 +200,7 @@ export class ChannelsClient extends BaseClient { * ``` */ addUsers(args: AddChannelUsersArgs): Promise { - return this.simple('POST', 'add_users', { ...args }, ChannelSchema) + return this.simple('POST', 'add_users', { ...args }, this.channelSchema) } /** @@ -197,7 +211,7 @@ export class ChannelsClient extends BaseClient { * @param args.userId - The user ID to remove. */ removeUser(args: RemoveChannelUserArgs): Promise { - return this.simple('POST', 'remove_user', { ...args }, ChannelSchema) + return this.simple('POST', 'remove_user', { ...args }, this.channelSchema) } /** @@ -208,7 +222,7 @@ export class ChannelsClient extends BaseClient { * @param args.userIds - Array of user IDs to remove. */ removeUsers(args: RemoveChannelUsersArgs): Promise { - return this.simple('POST', 'remove_users', { ...args }, ChannelSchema) + return this.simple('POST', 'remove_users', { ...args }, this.channelSchema) } private simple( diff --git a/src/clients/comments-client.test.ts b/src/clients/comments-client.test.ts index 6540b6a..01ef563 100644 --- a/src/clients/comments-client.test.ts +++ b/src/clients/comments-client.test.ts @@ -66,3 +66,64 @@ describe('CommentsClient — wire serialization', () => { }) }) }) + +describe('CommentsClient — baseUrl in entity links', () => { + it('roots the returned comment url at the configured baseUrl', async () => { + const customBase = 'https://comms.example.com' + const responseChannelId = '7YpL3oZ4kZ9vP7Q1tR2sX3y' + const responseCommentId = '7YpL3oZ4kZ9vP7Q1tR2sX41' + server.use( + http.post(`${customBase}/api/v1/comments/add`, () => + HttpResponse.json({ + id: responseCommentId, + thread_id: TEST_THREAD_ID, + channel_id: responseChannelId, + creator: 1, + content: 'hello', + posted_ts: Math.floor(Date.now() / 1000), + workspace_id: 1, + system_message: null, + }), + ), + ) + + const api = new CommsApi(TEST_API_TOKEN, { baseUrl: customBase }) + const comment = await api.comments.createComment({ + threadId: TEST_THREAD_ID, + content: 'hello', + }) + + expect(comment.url).toBe( + `${customBase}/a/1/ch/${responseChannelId}/t/${TEST_THREAD_ID}/c/${responseCommentId}`, + ) + }) + + it('strips a trailing slash on baseUrl so links do not double up', async () => { + const responseChannelId = '7YpL3oZ4kZ9vP7Q1tR2sX3y' + const responseCommentId = '7YpL3oZ4kZ9vP7Q1tR2sX41' + server.use( + http.post('https://comms.example.com/api/v1/comments/add', () => + HttpResponse.json({ + id: responseCommentId, + thread_id: TEST_THREAD_ID, + channel_id: responseChannelId, + creator: 1, + content: 'hello', + posted_ts: Math.floor(Date.now() / 1000), + workspace_id: 1, + system_message: null, + }), + ), + ) + + const api = new CommsApi(TEST_API_TOKEN, { baseUrl: 'https://comms.example.com/' }) + const comment = await api.comments.createComment({ + threadId: TEST_THREAD_ID, + content: 'hello', + }) + + expect(comment.url).toBe( + `https://comms.example.com/a/1/ch/${responseChannelId}/t/${TEST_THREAD_ID}/c/${responseCommentId}`, + ) + }) +}) diff --git a/src/clients/comments-client.ts b/src/clients/comments-client.ts index bfab80d..09f5af7 100644 --- a/src/clients/comments-client.ts +++ b/src/clients/comments-client.ts @@ -1,7 +1,13 @@ import { z } from 'zod' import { ENDPOINT_COMMENTS } from '../consts/endpoints' import { request } from '../transport/http-client' -import { type Comment, CommentSchema, type StatusOk, StatusOkSchema } from '../types/entities' +import { + type Comment, + CommentSchema, + createCommentSchema, + type StatusOk, + StatusOkSchema, +} from '../types/entities' import type { CreateCommentArgs, GetCommentsArgs, @@ -18,6 +24,9 @@ export const CommentListSchema = z.array(CommentSchema) * on `createComment` when the caller doesn't supply one. */ export class CommentsClient extends BaseClient { + private readonly commentSchema = createCommentSchema(this.getLinkBaseUrl()) + private readonly commentListSchema = z.array(this.commentSchema) + /** * Gets all comments for a thread. `newerThan` / `olderThan` (`Date`) are * converted to `newer_than_ts` / `older_than_ts` epoch seconds on the @@ -52,7 +61,7 @@ export class CommentsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => CommentListSchema.parse(response.data)) + }).then((response) => this.commentListSchema.parse(response.data)) } /** @@ -62,7 +71,9 @@ export class CommentsClient extends BaseClient { * @returns The comment object. */ getComment(id: string): Promise { - const wrappedSchema = z.object({ comment: CommentSchema }).transform((data) => data.comment) + const wrappedSchema = z + .object({ comment: this.commentSchema }) + .transform((data) => data.comment) return request({ httpMethod: 'GET', baseUri: this.getBaseUri(), @@ -102,7 +113,12 @@ export class CommentsClient extends BaseClient { */ createComment(args: CreateCommentArgs): Promise { return addCommentRequest( - { baseUri: this.getBaseUri(), apiToken: this.apiToken, customFetch: this.customFetch }, + { + baseUri: this.getBaseUri(), + apiToken: this.apiToken, + customFetch: this.customFetch, + schema: this.commentSchema, + }, args, ) } @@ -123,7 +139,7 @@ export class CommentsClient extends BaseClient { apiToken: this.apiToken, payload: { ...args }, customFetch: this.customFetch, - }).then((response) => CommentSchema.parse(response.data)) + }).then((response) => this.commentSchema.parse(response.data)) } /** diff --git a/src/clients/conversation-messages-client.ts b/src/clients/conversation-messages-client.ts index 2b10d7d..408b7f6 100644 --- a/src/clients/conversation-messages-client.ts +++ b/src/clients/conversation-messages-client.ts @@ -4,6 +4,7 @@ import { request } from '../transport/http-client' import { type ConversationMessage, ConversationMessageSchema, + createConversationMessageSchema, type StatusOk, StatusOkSchema, } from '../types/entities' @@ -22,6 +23,9 @@ export const ConversationMessageListSchema = z.array(ConversationMessageSchema) * message `id` on `createMessage` when the caller doesn't supply one. */ export class ConversationMessagesClient extends BaseClient { + private readonly messageSchema = createConversationMessageSchema(this.getLinkBaseUrl()) + private readonly messageListSchema = z.array(this.messageSchema) + /** * Gets all messages in a conversation. * @@ -55,7 +59,7 @@ export class ConversationMessagesClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => ConversationMessageListSchema.parse(response.data)) + }).then((response) => this.messageListSchema.parse(response.data)) } /** @@ -70,7 +74,7 @@ export class ConversationMessagesClient extends BaseClient { * ``` */ getMessage(id: string): Promise { - return this.simple('GET', 'getone', { id }, ConversationMessageSchema) + return this.simple('GET', 'getone', { id }, this.messageSchema) } /** @@ -104,7 +108,7 @@ export class ConversationMessagesClient extends BaseClient { if (args.directGroupMentions) params.directGroupMentions = args.directGroupMentions if (args.notify !== undefined) params.notify = args.notify - return this.simple('POST', 'add', params, ConversationMessageSchema) + return this.simple('POST', 'add', params, this.messageSchema) } /** @@ -131,7 +135,7 @@ export class ConversationMessagesClient extends BaseClient { if (args.directMentions) params.directMentions = args.directMentions if (args.directGroupMentions) params.directGroupMentions = args.directGroupMentions - return this.simple('POST', 'update', params, ConversationMessageSchema) + return this.simple('POST', 'update', params, this.messageSchema) } /** diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index 6166bfa..23af105 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -4,6 +4,7 @@ import { request } from '../transport/http-client' import { type Conversation, ConversationSchema, + createConversationSchema, type StatusOk, StatusOkSchema, type UnreadConversation, @@ -36,6 +37,9 @@ const GetUnreadResponseSchema = z.object({ * already-assigned `id` and your generated one is silently dropped. */ export class ConversationsClient extends BaseClient { + private readonly conversationSchema = createConversationSchema(this.getLinkBaseUrl()) + private readonly conversationListSchema = z.array(this.conversationSchema) + /** * Gets all conversations for a workspace. * @@ -58,7 +62,7 @@ export class ConversationsClient extends BaseClient { apiToken: this.apiToken, payload: args, customFetch: this.customFetch, - }).then((response) => ConversationListSchema.parse(response.data)) + }).then((response) => this.conversationListSchema.parse(response.data)) } /** @@ -68,7 +72,7 @@ export class ConversationsClient extends BaseClient { * @returns The conversation object. */ getConversation(id: string): Promise { - return this.simple('GET', 'getone', { id }, ConversationSchema) + return this.simple('GET', 'getone', { id }, this.conversationSchema) } /** @@ -94,7 +98,7 @@ export class ConversationsClient extends BaseClient { 'GET', 'get_or_create', { ...args, id: resolveCreateId(args.id) }, - ConversationSchema, + this.conversationSchema, ) } @@ -118,7 +122,7 @@ export class ConversationsClient extends BaseClient { updateConversation(args: UpdateConversationArgs): Promise { const params: Record = { id: args.id, title: args.title } if (args.archived !== undefined) params.archived = args.archived - return this.simple('POST', 'update', params, ConversationSchema) + return this.simple('POST', 'update', params, this.conversationSchema) } /** @@ -128,7 +132,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ archiveConversation(id: string): Promise { - return this.simple('GET', 'archive', { id }, ConversationSchema) + return this.simple('GET', 'archive', { id }, this.conversationSchema) } /** @@ -138,7 +142,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ unarchiveConversation(id: string): Promise { - return this.simple('GET', 'unarchive', { id }, ConversationSchema) + return this.simple('GET', 'unarchive', { id }, this.conversationSchema) } /** @@ -150,7 +154,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ addUser(args: AddConversationUserArgs): Promise { - return this.simple('POST', 'add_user', { ...args }, ConversationSchema) + return this.simple('POST', 'add_user', { ...args }, this.conversationSchema) } /** @@ -167,7 +171,7 @@ export class ConversationsClient extends BaseClient { * ``` */ addUsers(args: AddConversationUsersArgs): Promise { - return this.simple('POST', 'add_users', { ...args }, ConversationSchema) + return this.simple('POST', 'add_users', { ...args }, this.conversationSchema) } /** @@ -179,7 +183,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ removeUser(args: RemoveConversationUserArgs): Promise { - return this.simple('POST', 'remove_user', { ...args }, ConversationSchema) + return this.simple('POST', 'remove_user', { ...args }, this.conversationSchema) } /** @@ -191,7 +195,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ removeUsers(args: RemoveConversationUsersArgs): Promise { - return this.simple('POST', 'remove_users', { ...args }, ConversationSchema) + return this.simple('POST', 'remove_users', { ...args }, this.conversationSchema) } /** @@ -253,7 +257,7 @@ export class ConversationsClient extends BaseClient { * ``` */ muteConversation(args: MuteConversationArgs): Promise { - return this.simple('GET', 'mute', { ...args }, ConversationSchema) + return this.simple('GET', 'mute', { ...args }, this.conversationSchema) } /** @@ -263,7 +267,7 @@ export class ConversationsClient extends BaseClient { * @returns The updated conversation object. */ unmuteConversation(id: string): Promise { - return this.simple('GET', 'unmute', { id }, ConversationSchema) + return this.simple('GET', 'unmute', { id }, this.conversationSchema) } private simple( diff --git a/src/clients/inbox-client.ts b/src/clients/inbox-client.ts index aec32c2..c32df0a 100644 --- a/src/clients/inbox-client.ts +++ b/src/clients/inbox-client.ts @@ -1,6 +1,6 @@ import { ENDPOINT_INBOX } from '../consts/endpoints' import { request } from '../transport/http-client' -import { type InboxThread, InboxThreadSchema } from '../types/entities' +import { createInboxThreadSchema, type InboxThread } from '../types/entities' import type { ArchiveAllArgs, GetInboxArgs } from '../types/requests' import { BaseClient } from './base-client' @@ -11,6 +11,8 @@ type InboxCountResponse = { /** Client for `/api/v1/inbox/`. */ export class InboxClient extends BaseClient { + private readonly inboxThreadSchema = createInboxThreadSchema(this.getLinkBaseUrl()) + /** * Gets inbox items (threads). * @@ -52,7 +54,7 @@ export class InboxClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((thread) => InboxThreadSchema.parse(thread))) + }).then((response) => response.data.map((thread) => this.inboxThreadSchema.parse(thread))) } /** diff --git a/src/clients/threads-client.ts b/src/clients/threads-client.ts index 853b7c4..301b1ca 100644 --- a/src/clients/threads-client.ts +++ b/src/clients/threads-client.ts @@ -3,6 +3,8 @@ import { ENDPOINT_THREADS } from '../consts/endpoints' import { request } from '../transport/http-client' import { type Comment, + createCommentSchema, + createThreadSchema, type StatusOk, StatusOkSchema, type Thread, @@ -40,6 +42,10 @@ const GetUnreadResponseSchema = z.object({ * `createThread` when the caller doesn't supply one. */ export class ThreadsClient extends BaseClient { + private readonly threadSchema = createThreadSchema(this.getLinkBaseUrl()) + private readonly threadListSchema = z.array(this.threadSchema) + private readonly commentSchema = createCommentSchema(this.getLinkBaseUrl()) + /** * Gets threads. At least one of `channelId` / `workspaceId` is required. * `newerThan` / `olderThan` (`Date`) are converted to the @@ -78,7 +84,7 @@ export class ThreadsClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => ThreadListSchema.parse(response.data)) + }).then((response) => this.threadListSchema.parse(response.data)) } /** @@ -88,7 +94,7 @@ export class ThreadsClient extends BaseClient { * @returns The thread object. */ getThread(id: string): Promise { - return this.simple('GET', 'getone', { id }, ThreadSchema) + return this.simple('GET', 'getone', { id }, this.threadSchema) } /** @@ -112,7 +118,12 @@ export class ThreadsClient extends BaseClient { * ``` */ createThread(args: CreateThreadArgs): Promise { - return this.simple('POST', 'add', { ...args, id: resolveCreateId(args.id) }, ThreadSchema) + return this.simple( + 'POST', + 'add', + { ...args, id: resolveCreateId(args.id) }, + this.threadSchema, + ) } /** @@ -125,7 +136,7 @@ export class ThreadsClient extends BaseClient { * @returns The updated thread object. */ updateThread(args: UpdateThreadArgs): Promise { - return this.simple('POST', 'update', { ...args }, ThreadSchema) + return this.simple('POST', 'update', { ...args }, this.threadSchema) } /** @@ -182,7 +193,7 @@ export class ThreadsClient extends BaseClient { * @returns The updated thread object. */ moveToChannel(args: MoveThreadToChannelArgs): Promise { - return this.simple('GET', 'move_to_channel', { ...args }, ThreadSchema) + return this.simple('GET', 'move_to_channel', { ...args }, this.threadSchema) } /** @@ -281,7 +292,7 @@ export class ThreadsClient extends BaseClient { * ``` */ muteThread(args: MuteThreadArgs): Promise { - return this.simple('GET', 'mute', { ...args }, ThreadSchema) + return this.simple('GET', 'mute', { ...args }, this.threadSchema) } /** @@ -292,7 +303,7 @@ export class ThreadsClient extends BaseClient { * @returns The updated thread object. */ unmuteThread(id: string): Promise { - return this.simple('GET', 'unmute', { id }, ThreadSchema) + return this.simple('GET', 'unmute', { id }, this.threadSchema) } /** @@ -359,7 +370,12 @@ export class ThreadsClient extends BaseClient { ): Promise { const { id, ...rest } = args return addCommentRequest( - { baseUri: this.getBaseUri(), apiToken: this.apiToken, customFetch: this.customFetch }, + { + baseUri: this.getBaseUri(), + apiToken: this.apiToken, + customFetch: this.customFetch, + schema: this.commentSchema, + }, { threadId: id, ...rest }, { threadAction }, ) diff --git a/src/clients/workspaces-client.ts b/src/clients/workspaces-client.ts index 05810d1..46cdff2 100644 --- a/src/clients/workspaces-client.ts +++ b/src/clients/workspaces-client.ts @@ -1,7 +1,13 @@ import { z } from 'zod' import { ENDPOINT_WORKSPACES } from '../consts/endpoints' import { request } from '../transport/http-client' -import { Channel, ChannelSchema, Workspace, WorkspaceSchema } from '../types/entities' +import { + Channel, + ChannelSchema, + createChannelSchema, + Workspace, + WorkspaceSchema, +} from '../types/entities' import { BaseClient } from './base-client' export const ChannelListSchema = z.array(ChannelSchema) @@ -11,6 +17,8 @@ export const ChannelListSchema = z.array(ChannelSchema) * currently rejects any `color` other than `1` on add/update. */ export class WorkspacesClient extends BaseClient { + private readonly channelListSchema = z.array(createChannelSchema(this.getLinkBaseUrl())) + /** * Gets all the user's workspaces. * @@ -165,6 +173,6 @@ export class WorkspacesClient extends BaseClient { apiToken: this.apiToken, payload: { id }, customFetch: this.customFetch, - }).then((response) => ChannelListSchema.parse(response.data)) + }).then((response) => this.channelListSchema.parse(response.data)) } } diff --git a/src/types/entities.test.ts b/src/types/entities.test.ts index 4a1eca5..634ea2f 100644 --- a/src/types/entities.test.ts +++ b/src/types/entities.test.ts @@ -1,4 +1,4 @@ -import { AttachmentSchema } from './entities' +import { AttachmentSchema, createChannelSchema, createInboxThreadSchema } from './entities' describe('AttachmentSchema', () => { it('parses a minimal payload (only required fields)', () => { @@ -92,3 +92,73 @@ describe('AttachmentSchema', () => { ).toThrow() }) }) + +describe('entity url factories', () => { + const base = 'https://comms.example.com' + const channelId = '7YpL3oZ4kZ9vP7Q1tR2sX44' + const threadId = '7YpL3oZ4kZ9vP7Q1tR2sX3z' + const commentThreadId = '7YpL3oZ4kZ9vP7Q1tR2sX55' + const commentId = '7YpL3oZ4kZ9vP7Q1tR2sX41' + + it('threads the link base into the generated url', () => { + const channel = { + id: channelId, + name: 'Engineering', + creator: 1, + public: true, + workspaceId: 1, + archived: false, + created: new Date(), + version: 1, + } + expect(createChannelSchema(base).parse(channel).url).toBe(`${base}/a/1/ch/${channelId}/`) + }) + + it('falls back to the default web app when no base is given', () => { + const channel = { + id: channelId, + name: 'Engineering', + creator: 1, + public: true, + workspaceId: 1, + archived: false, + created: new Date(), + version: 1, + } + expect(createChannelSchema().parse(channel).url).toBe( + `https://comms.todoist.com/a/1/ch/${channelId}/`, + ) + }) + + it('propagates the base to a nested lastComment on an inbox thread', () => { + const parsed = createInboxThreadSchema(base).parse({ + id: threadId, + title: 't', + content: 'c', + creator: 1, + channelId, + workspaceId: 1, + commentCount: 0, + lastUpdated: new Date(), + posted: new Date(), + snippet: 's', + snippetCreator: 1, + isArchived: false, + inInbox: true, + closed: false, + lastComment: { + id: commentId, + content: 'hi', + creator: 1, + threadId: commentThreadId, + channelId, + workspaceId: 1, + posted: new Date(), + }, + }) + expect(parsed.url).toBe(`${base}/a/1/ch/${channelId}/t/${threadId}/`) + expect(parsed.lastComment?.url).toBe( + `${base}/a/1/ch/${channelId}/t/${commentThreadId}/c/${commentId}`, + ) + }) +}) diff --git a/src/types/entities.ts b/src/types/entities.ts index ed96164..3df72fe 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -112,109 +112,120 @@ export const WorkspaceSchema = z.object({ export type Workspace = z.infer -export const ChannelSchema = z - .object({ - id: z.string(), - name: z.string(), - description: z.string().nullable().optional(), - creator: z.number(), - userIds: z.array(z.number()).nullable().optional(), - color: z.number().nullable().optional(), - public: z.boolean(), - workspaceId: z.number(), - archived: z.boolean(), - created: z.date(), - useDefaultRecipients: z.boolean().nullable().optional(), - defaultGroups: z.array(z.string()).nullable().optional(), - defaultRecipients: z.array(z.number()).nullable().optional(), - isFavorited: z.boolean().nullable().optional(), - icon: z.number().nullable().optional(), - version: z.number(), - filters: z.record(z.string(), z.string()).nullable().optional(), - }) - .transform((data) => ({ +export const ChannelObjectSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + creator: z.number(), + userIds: z.array(z.number()).nullable().optional(), + color: z.number().nullable().optional(), + public: z.boolean(), + workspaceId: z.number(), + archived: z.boolean(), + created: z.date(), + useDefaultRecipients: z.boolean().nullable().optional(), + defaultGroups: z.array(z.string()).nullable().optional(), + defaultRecipients: z.array(z.number()).nullable().optional(), + isFavorited: z.boolean().nullable().optional(), + icon: z.number().nullable().optional(), + version: z.number(), + filters: z.record(z.string(), z.string()).nullable().optional(), +}) + +export function createChannelSchema(linkBaseUrl?: string) { + return ChannelObjectSchema.transform((data) => ({ ...data, - url: getFullCommsURL({ workspaceId: data.workspaceId, channelId: data.id }), + url: getFullCommsURL({ workspaceId: data.workspaceId, channelId: data.id }, linkBaseUrl), })) +} + +export const ChannelSchema = createChannelSchema() export type Channel = z.infer // 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(), - title: z.string(), - content: z.string(), - creator: z.number(), - creatorName: z.string().nullable().optional(), - channelId: z.string(), - workspaceId: z.number(), - actions: z.array(z.unknown()).nullable().optional(), - attachments: z.array(AttachmentSchema).nullable().optional(), - commentCount: z.number(), - closed: z.boolean().nullable().optional(), - directGroupMentions: z.array(z.string()).nullable().optional(), - directMentions: z.array(z.number()).nullable().optional(), - groups: z.array(z.string()).nullable().optional(), - lastEdited: z.date().nullable().optional(), - lastObjIndex: z.number().nullable().optional(), - lastUpdated: z.date(), - mutedUntil: z.date().nullable().optional(), - participants: z.array(z.number()).nullable().optional(), - // 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(), - recipients: z.array(z.number()).nullable().optional(), - responders: z.array(z.number()).nullable().optional(), - snippet: z.string(), - snippetCreator: z.number(), - snippetMaskAvatarUrl: z.string().nullable().optional(), - snippetMaskPoster: z.string().nullable().optional(), - systemMessage: SystemMessageSchema, - toEmails: z.array(z.string()).nullable().optional(), - isArchived: z.boolean(), - isSaved: z.boolean().nullable().optional(), - inInbox: z.boolean().nullable().optional(), - lastComment: z - .object({ - id: z.string(), - content: z.string(), - creator: z.number(), - creatorName: z.string(), - threadId: z.string(), - channelId: z.string(), - posted: z.date(), - systemMessage: SystemMessageSchema, - attachments: z.array(AttachmentSchema).nullable().optional(), - reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), - actions: z.array(z.unknown()).nullable().optional(), - objIndex: z.number(), - lastEdited: z.date().nullable().optional(), - deleted: z.boolean(), - deletedBy: z.number().nullable().optional(), - directGroupMentions: z.array(z.string()).nullable().optional(), - directMentions: z.array(z.number()).nullable().optional(), - groups: z.array(z.string()).nullable().optional(), - recipients: z.array(z.number()).nullable().optional(), - toEmails: z.array(z.string()).nullable().optional(), - version: z.number(), - workspaceId: z.number(), - }) - .nullable() - .optional(), - }) - .transform((data) => ({ +export const ThreadObjectSchema = z.object({ + id: z.string(), + title: z.string(), + content: z.string(), + creator: z.number(), + creatorName: z.string().nullable().optional(), + channelId: z.string(), + workspaceId: z.number(), + actions: z.array(z.unknown()).nullable().optional(), + attachments: z.array(AttachmentSchema).nullable().optional(), + commentCount: z.number(), + closed: z.boolean().nullable().optional(), + directGroupMentions: z.array(z.string()).nullable().optional(), + directMentions: z.array(z.number()).nullable().optional(), + groups: z.array(z.string()).nullable().optional(), + lastEdited: z.date().nullable().optional(), + lastObjIndex: z.number().nullable().optional(), + lastUpdated: z.date(), + mutedUntil: z.date().nullable().optional(), + participants: z.array(z.number()).nullable().optional(), + // 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(), + recipients: z.array(z.number()).nullable().optional(), + responders: z.array(z.number()).nullable().optional(), + snippet: z.string(), + snippetCreator: z.number(), + snippetMaskAvatarUrl: z.string().nullable().optional(), + snippetMaskPoster: z.string().nullable().optional(), + systemMessage: SystemMessageSchema, + toEmails: z.array(z.string()).nullable().optional(), + isArchived: z.boolean(), + isSaved: z.boolean().nullable().optional(), + inInbox: z.boolean().nullable().optional(), + lastComment: z + .object({ + id: z.string(), + content: z.string(), + creator: z.number(), + creatorName: z.string(), + threadId: z.string(), + channelId: z.string(), + posted: z.date(), + systemMessage: SystemMessageSchema, + attachments: z.array(AttachmentSchema).nullable().optional(), + reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), + actions: z.array(z.unknown()).nullable().optional(), + objIndex: z.number(), + lastEdited: z.date().nullable().optional(), + deleted: z.boolean(), + deletedBy: z.number().nullable().optional(), + directGroupMentions: z.array(z.string()).nullable().optional(), + directMentions: z.array(z.number()).nullable().optional(), + groups: z.array(z.string()).nullable().optional(), + recipients: z.array(z.number()).nullable().optional(), + toEmails: z.array(z.string()).nullable().optional(), + version: z.number(), + workspaceId: z.number(), + }) + .nullable() + .optional(), +}) + +export function createThreadSchema(linkBaseUrl?: string) { + return ThreadObjectSchema.transform((data) => ({ ...data, - url: getFullCommsURL({ - workspaceId: data.workspaceId, - channelId: data.channelId, - threadId: data.id, - }), + url: getFullCommsURL( + { + workspaceId: data.workspaceId, + channelId: data.channelId, + threadId: data.id, + }, + linkBaseUrl, + ), })) +} + +export const ThreadSchema = createThreadSchema() export type Thread = z.infer @@ -233,87 +244,101 @@ export const GroupSchema = z.object({ export type Group = z.infer // Conversation entity from API. -export const ConversationSchema = z - .object({ - id: z.string(), - workspaceId: z.number(), - userIds: z.array(z.number()), - messageCount: z.number().nullable().optional(), - lastObjIndex: z.number(), - snippet: z.string(), - snippetCreators: z.array(z.number()), - lastActive: z.date(), - mutedUntil: z.date().nullable().optional(), - archived: z.boolean(), - created: z.date(), - creator: z.number(), - title: z.string().nullable().optional(), - private: z.boolean().nullable().optional(), - lastMessage: z - .object({ - // `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(), - conversationId: z.string(), - posted: z.date(), - systemMessage: SystemMessageSchema, - attachments: z.array(AttachmentSchema).nullable().optional(), - reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), - actions: z.array(z.unknown()).nullable().optional(), - objIndex: z.number().nullable().optional(), - lastEdited: z.date().nullable().optional(), - deleted: z.boolean().nullable().optional(), - directGroupMentions: z.array(z.string()).nullable().optional(), - directMentions: z.array(z.number()).nullable().optional(), - version: z.number().nullable().optional(), - workspaceId: z.number().nullable().optional(), - }) - .nullable() - .optional(), - }) - .transform((data) => ({ +export const ConversationObjectSchema = z.object({ + id: z.string(), + workspaceId: z.number(), + userIds: z.array(z.number()), + messageCount: z.number().nullable().optional(), + lastObjIndex: z.number(), + snippet: z.string(), + snippetCreators: z.array(z.number()), + lastActive: z.date(), + mutedUntil: z.date().nullable().optional(), + archived: z.boolean(), + created: z.date(), + creator: z.number(), + title: z.string().nullable().optional(), + private: z.boolean().nullable().optional(), + lastMessage: z + .object({ + // `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(), + conversationId: z.string(), + posted: z.date(), + systemMessage: SystemMessageSchema, + attachments: z.array(AttachmentSchema).nullable().optional(), + reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), + actions: z.array(z.unknown()).nullable().optional(), + objIndex: z.number().nullable().optional(), + lastEdited: z.date().nullable().optional(), + deleted: z.boolean().nullable().optional(), + directGroupMentions: z.array(z.string()).nullable().optional(), + directMentions: z.array(z.number()).nullable().optional(), + version: z.number().nullable().optional(), + workspaceId: z.number().nullable().optional(), + }) + .nullable() + .optional(), +}) + +export function createConversationSchema(linkBaseUrl?: string) { + return ConversationObjectSchema.transform((data) => ({ ...data, - url: getFullCommsURL({ workspaceId: data.workspaceId, conversationId: data.id }), + url: getFullCommsURL( + { workspaceId: data.workspaceId, conversationId: data.id }, + linkBaseUrl, + ), })) +} + +export const ConversationSchema = createConversationSchema() export type Conversation = z.infer -export const CommentSchema = z - .object({ - id: z.string(), - content: z.string(), - creator: z.number(), - threadId: z.string(), - workspaceId: z.number(), - conversationId: z.string().nullable().optional(), - posted: z.date(), - lastEdited: z.date().nullable().optional(), - directMentions: z.array(z.number()).nullable().optional(), - directGroupMentions: z.array(z.string()).nullable().optional(), - systemMessage: SystemMessageSchema, - attachments: z.array(AttachmentSchema).nullable().optional(), - reactions: z.record(z.string(), z.unknown()).nullable().optional(), - objIndex: z.number().nullable().optional(), - creatorName: z.string().nullable().optional(), - channelId: z.string(), - recipients: z.array(z.number()).nullable().optional(), - groups: z.array(z.string()).nullable().optional(), - toEmails: z.array(z.string()).nullable().optional(), - deleted: z.boolean().nullable().optional(), - deletedBy: z.number().nullable().optional(), - version: z.number().nullable().optional(), - actions: z.array(z.unknown()).nullable().optional(), - }) - .transform((data) => ({ +export const CommentObjectSchema = z.object({ + id: z.string(), + content: z.string(), + creator: z.number(), + threadId: z.string(), + workspaceId: z.number(), + conversationId: z.string().nullable().optional(), + posted: z.date(), + lastEdited: z.date().nullable().optional(), + directMentions: z.array(z.number()).nullable().optional(), + directGroupMentions: z.array(z.string()).nullable().optional(), + systemMessage: SystemMessageSchema, + attachments: z.array(AttachmentSchema).nullable().optional(), + reactions: z.record(z.string(), z.unknown()).nullable().optional(), + objIndex: z.number().nullable().optional(), + creatorName: z.string().nullable().optional(), + channelId: z.string(), + recipients: z.array(z.number()).nullable().optional(), + groups: z.array(z.string()).nullable().optional(), + toEmails: z.array(z.string()).nullable().optional(), + deleted: z.boolean().nullable().optional(), + deletedBy: z.number().nullable().optional(), + version: z.number().nullable().optional(), + actions: z.array(z.unknown()).nullable().optional(), +}) + +export function createCommentSchema(linkBaseUrl?: string) { + return CommentObjectSchema.transform((data) => ({ ...data, - url: getFullCommsURL({ - workspaceId: data.workspaceId, - channelId: data.channelId, - threadId: data.threadId, - commentId: data.id, - }), + url: getFullCommsURL( + { + workspaceId: data.workspaceId, + channelId: data.channelId, + threadId: data.threadId, + commentId: data.id, + }, + linkBaseUrl, + ), })) +} + +export const CommentSchema = createCommentSchema() export type Comment = z.infer @@ -332,39 +357,46 @@ export type WorkspaceUser = z.infer // (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), - content: z.string(), - creator: z.number(), - conversationId: z.string(), - posted: z.date(), - systemMessage: SystemMessageSchema, - attachments: z.array(AttachmentSchema).nullable().optional(), - reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), - actions: z.array(z.unknown()).nullable().optional(), - objIndex: z.number().nullable().optional(), - lastEdited: z.date().nullable().optional(), - isDeleted: z.boolean().nullable().optional(), - directGroupMentions: z.array(z.string()).nullable().optional(), - directMentions: z.array(z.number()).nullable().optional(), - version: z.number().nullable().optional(), - workspaceId: z.number(), - }) - .transform((data) => ({ +export const ConversationMessageObjectSchema = z.object({ + id: z.union([z.string(), z.number()]).transform(String), + content: z.string(), + creator: z.number(), + conversationId: z.string(), + posted: z.date(), + systemMessage: SystemMessageSchema, + attachments: z.array(AttachmentSchema).nullable().optional(), + reactions: z.record(z.string(), z.array(z.number())).nullable().optional(), + actions: z.array(z.unknown()).nullable().optional(), + objIndex: z.number().nullable().optional(), + lastEdited: z.date().nullable().optional(), + isDeleted: z.boolean().nullable().optional(), + directGroupMentions: z.array(z.string()).nullable().optional(), + directMentions: z.array(z.number()).nullable().optional(), + version: z.number().nullable().optional(), + workspaceId: z.number(), +}) + +export function createConversationMessageSchema(linkBaseUrl?: string) { + return ConversationMessageObjectSchema.transform((data) => ({ ...data, - url: getFullCommsURL({ - workspaceId: data.workspaceId, - conversationId: data.conversationId, - messageId: data.id, - }), + url: getFullCommsURL( + { + workspaceId: data.workspaceId, + conversationId: data.conversationId, + messageId: data.id, + }, + linkBaseUrl, + ), })) +} + +export const ConversationMessageSchema = createConversationMessageSchema() export type ConversationMessage = z.infer // InboxThread entity from API - returns full Thread objects with additional inbox metadata. -export const InboxThreadSchema = z - .object({ +function createInboxThreadObjectSchema(linkBaseUrl?: string) { + return z.object({ id: z.string(), title: z.string(), content: z.string(), @@ -400,18 +432,27 @@ export const InboxThreadSchema = z isSaved: z.boolean().nullable().optional(), closed: z.boolean(), responders: z.array(z.number()).nullable().optional(), - lastComment: CommentSchema.nullable().optional(), + lastComment: createCommentSchema(linkBaseUrl).nullable().optional(), toEmails: z.array(z.string()).nullable().optional(), version: z.number().nullable().optional(), }) - .transform((data) => ({ +} + +export function createInboxThreadSchema(linkBaseUrl?: string) { + return createInboxThreadObjectSchema(linkBaseUrl).transform((data) => ({ ...data, - url: getFullCommsURL({ - workspaceId: data.workspaceId, - channelId: data.channelId, - threadId: data.id, - }), + url: getFullCommsURL( + { + workspaceId: data.workspaceId, + channelId: data.channelId, + threadId: data.id, + }, + linkBaseUrl, + ), })) +} + +export const InboxThreadSchema = createInboxThreadSchema() export type InboxThread = z.infer From ee96a1c62482d7ff9f600d6cc6780e0bbce9c0ba Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 22 May 2026 16:39:24 +0100 Subject: [PATCH 2/2] refactor: address doistbot review on baseUrl entity-link fix - Normalize a trailing slash in `getFullCommsURL` so direct schema consumers (`createXxxSchema('https://x/')`) get the same single-slash links as `CommsApi` clients; `getLinkBaseUrl` now returns the value verbatim. - Reuse the exported singleton schemas/list schemas when no custom `baseUrl` is configured, so the common `new CommsApi(token)` path no longer builds duplicate Zod wrappers per client. - Lift the `getone` wrapped-comment schema off the request hot path to a per-client field. - Add `InboxThreadListSchema` and parse the inbox list through it. - Export `createInboxThreadObjectSchema` per the all-schemas-exported rule. - Tests: trailing-slash normalization for a direct schema consumer, and a base-bound schema assertion through the add-comment helper (close/reopen path). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients/add-comment-helper.test.ts | 18 +++++++++++++++++- src/clients/base-client.ts | 9 +++------ src/clients/channels-client.ts | 10 ++++++++-- src/clients/comments-client.ts | 19 +++++++++++++------ src/clients/conversation-messages-client.ts | 10 ++++++++-- src/clients/conversations-client.ts | 10 ++++++++-- src/clients/inbox-client.ts | 16 +++++++++++++--- src/clients/threads-client.ts | 15 ++++++++++++--- src/clients/workspaces-client.ts | 6 +++++- src/types/entities.test.ts | 16 ++++++++++++++++ src/types/entities.ts | 2 +- src/utils/url-helpers.ts | 4 +++- 12 files changed, 107 insertions(+), 28 deletions(-) diff --git a/src/clients/add-comment-helper.test.ts b/src/clients/add-comment-helper.test.ts index b1c6942..d4838bf 100644 --- a/src/clients/add-comment-helper.test.ts +++ b/src/clients/add-comment-helper.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { getCommsBaseUri } from '../consts/endpoints' import { server } from '../testUtils/msw-setup' import { TEST_API_BASE_URL, TEST_API_TOKEN, TEST_THREAD_ID } from '../testUtils/test-defaults' -import { CommentSchema } from '../types/entities' +import { CommentSchema, createCommentSchema } from '../types/entities' import { EVERYONE, EVERYONE_IN_THREAD } from '../types/enums' import { addCommentRequest } from './add-comment-helper' @@ -21,6 +21,22 @@ const COMMENT_RESPONSE = { system_message: null, } +describe('addCommentRequest — base-bound schema', () => { + it('roots the returned comment url at the schema base (close/reopen path)', async () => { + const customBase = 'https://comms.example.com' + server.use(http.post(COMMENT_ADD, () => HttpResponse.json(COMMENT_RESPONSE))) + + const comment = await addCommentRequest( + { ...ctx, schema: createCommentSchema(customBase) }, + { threadId: TEST_THREAD_ID, content: 'hello' }, + ) + + expect(comment.url).toBe( + `${customBase}/a/1/ch/BBBBBBBBBBBBBBBBBBBBBB/t/${TEST_THREAD_ID}/c/AAAAAAAAAAAAAAAAAAAAAA`, + ) + }) +}) + describe('addCommentRequest — reserved broadcast marker validation', () => { it('throws when a marker is passed in `groups`', () => { expect(() => diff --git a/src/clients/base-client.ts b/src/clients/base-client.ts index 7cd7db6..68f3f23 100644 --- a/src/clients/base-client.ts +++ b/src/clients/base-client.ts @@ -41,13 +41,10 @@ export class BaseClient { /** * Base URL for entity web links, or `undefined` to use getFullCommsURL's - * default web app. + * default web app. Trailing-slash normalization happens in + * `getFullCommsURL`, so the configured value is returned verbatim. */ protected getLinkBaseUrl(): string | undefined { - if (!this.baseUrl) { - return undefined - } - // Strip a trailing slash so links don't double up, since entity paths start with '/'. - return this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl + return this.baseUrl } } diff --git a/src/clients/channels-client.ts b/src/clients/channels-client.ts index dea8830..938dd49 100644 --- a/src/clients/channels-client.ts +++ b/src/clients/channels-client.ts @@ -28,8 +28,14 @@ export const ChannelListSchema = z.array(ChannelSchema) * to keep an optimistic-UI ID stable through the round-trip. */ export class ChannelsClient extends BaseClient { - private readonly channelSchema = createChannelSchema(this.getLinkBaseUrl()) - private readonly channelListSchema = z.array(this.channelSchema) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly channelSchema = this.linkBaseUrl + ? createChannelSchema(this.linkBaseUrl) + : ChannelSchema + private readonly channelListSchema = this.linkBaseUrl + ? z.array(this.channelSchema) + : ChannelListSchema /** * Gets all channels for a given workspace. diff --git a/src/clients/comments-client.ts b/src/clients/comments-client.ts index 09f5af7..33c55ac 100644 --- a/src/clients/comments-client.ts +++ b/src/clients/comments-client.ts @@ -24,8 +24,18 @@ export const CommentListSchema = z.array(CommentSchema) * on `createComment` when the caller doesn't supply one. */ export class CommentsClient extends BaseClient { - private readonly commentSchema = createCommentSchema(this.getLinkBaseUrl()) - private readonly commentListSchema = z.array(this.commentSchema) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly commentSchema = this.linkBaseUrl + ? createCommentSchema(this.linkBaseUrl) + : CommentSchema + private readonly commentListSchema = this.linkBaseUrl + ? z.array(this.commentSchema) + : CommentListSchema + // `getone` wraps the comment in `{ comment: ... }`; built once per client. + private readonly wrappedCommentSchema = z + .object({ comment: this.commentSchema }) + .transform((data) => data.comment) /** * Gets all comments for a thread. `newerThan` / `olderThan` (`Date`) are @@ -71,9 +81,6 @@ export class CommentsClient extends BaseClient { * @returns The comment object. */ getComment(id: string): Promise { - const wrappedSchema = z - .object({ comment: this.commentSchema }) - .transform((data) => data.comment) return request({ httpMethod: 'GET', baseUri: this.getBaseUri(), @@ -81,7 +88,7 @@ export class CommentsClient extends BaseClient { apiToken: this.apiToken, payload: { id }, customFetch: this.customFetch, - }).then((response) => wrappedSchema.parse(response.data)) + }).then((response) => this.wrappedCommentSchema.parse(response.data)) } /** diff --git a/src/clients/conversation-messages-client.ts b/src/clients/conversation-messages-client.ts index 408b7f6..99a23be 100644 --- a/src/clients/conversation-messages-client.ts +++ b/src/clients/conversation-messages-client.ts @@ -23,8 +23,14 @@ export const ConversationMessageListSchema = z.array(ConversationMessageSchema) * message `id` on `createMessage` when the caller doesn't supply one. */ export class ConversationMessagesClient extends BaseClient { - private readonly messageSchema = createConversationMessageSchema(this.getLinkBaseUrl()) - private readonly messageListSchema = z.array(this.messageSchema) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly messageSchema = this.linkBaseUrl + ? createConversationMessageSchema(this.linkBaseUrl) + : ConversationMessageSchema + private readonly messageListSchema = this.linkBaseUrl + ? z.array(this.messageSchema) + : ConversationMessageListSchema /** * Gets all messages in a conversation. diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index 23af105..e762a9c 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -37,8 +37,14 @@ const GetUnreadResponseSchema = z.object({ * already-assigned `id` and your generated one is silently dropped. */ export class ConversationsClient extends BaseClient { - private readonly conversationSchema = createConversationSchema(this.getLinkBaseUrl()) - private readonly conversationListSchema = z.array(this.conversationSchema) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly conversationSchema = this.linkBaseUrl + ? createConversationSchema(this.linkBaseUrl) + : ConversationSchema + private readonly conversationListSchema = this.linkBaseUrl + ? z.array(this.conversationSchema) + : ConversationListSchema /** * Gets all conversations for a workspace. diff --git a/src/clients/inbox-client.ts b/src/clients/inbox-client.ts index c32df0a..0506074 100644 --- a/src/clients/inbox-client.ts +++ b/src/clients/inbox-client.ts @@ -1,9 +1,12 @@ +import { z } from 'zod' import { ENDPOINT_INBOX } from '../consts/endpoints' import { request } from '../transport/http-client' -import { createInboxThreadSchema, type InboxThread } from '../types/entities' +import { createInboxThreadSchema, type InboxThread, InboxThreadSchema } from '../types/entities' import type { ArchiveAllArgs, GetInboxArgs } from '../types/requests' import { BaseClient } from './base-client' +export const InboxThreadListSchema = z.array(InboxThreadSchema) + type InboxCountResponse = { data: number version: number @@ -11,7 +14,14 @@ type InboxCountResponse = { /** Client for `/api/v1/inbox/`. */ export class InboxClient extends BaseClient { - private readonly inboxThreadSchema = createInboxThreadSchema(this.getLinkBaseUrl()) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly inboxThreadSchema = this.linkBaseUrl + ? createInboxThreadSchema(this.linkBaseUrl) + : InboxThreadSchema + private readonly inboxThreadListSchema = this.linkBaseUrl + ? z.array(this.inboxThreadSchema) + : InboxThreadListSchema /** * Gets inbox items (threads). @@ -54,7 +64,7 @@ export class InboxClient extends BaseClient { apiToken: this.apiToken, payload: params, customFetch: this.customFetch, - }).then((response) => response.data.map((thread) => this.inboxThreadSchema.parse(thread))) + }).then((response) => this.inboxThreadListSchema.parse(response.data)) } /** diff --git a/src/clients/threads-client.ts b/src/clients/threads-client.ts index 301b1ca..6f1ec30 100644 --- a/src/clients/threads-client.ts +++ b/src/clients/threads-client.ts @@ -3,6 +3,7 @@ import { ENDPOINT_THREADS } from '../consts/endpoints' import { request } from '../transport/http-client' import { type Comment, + CommentSchema, createCommentSchema, createThreadSchema, type StatusOk, @@ -42,9 +43,17 @@ const GetUnreadResponseSchema = z.object({ * `createThread` when the caller doesn't supply one. */ export class ThreadsClient extends BaseClient { - private readonly threadSchema = createThreadSchema(this.getLinkBaseUrl()) - private readonly threadListSchema = z.array(this.threadSchema) - private readonly commentSchema = createCommentSchema(this.getLinkBaseUrl()) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singletons when no custom base is configured. + private readonly threadSchema = this.linkBaseUrl + ? createThreadSchema(this.linkBaseUrl) + : ThreadSchema + private readonly threadListSchema = this.linkBaseUrl + ? z.array(this.threadSchema) + : ThreadListSchema + private readonly commentSchema = this.linkBaseUrl + ? createCommentSchema(this.linkBaseUrl) + : CommentSchema /** * Gets threads. At least one of `channelId` / `workspaceId` is required. diff --git a/src/clients/workspaces-client.ts b/src/clients/workspaces-client.ts index 46cdff2..57bde2b 100644 --- a/src/clients/workspaces-client.ts +++ b/src/clients/workspaces-client.ts @@ -17,7 +17,11 @@ export const ChannelListSchema = z.array(ChannelSchema) * currently rejects any `color` other than `1` on add/update. */ export class WorkspacesClient extends BaseClient { - private readonly channelListSchema = z.array(createChannelSchema(this.getLinkBaseUrl())) + private readonly linkBaseUrl = this.getLinkBaseUrl() + // Reuse the shared singleton when no custom base is configured. + private readonly channelListSchema = this.linkBaseUrl + ? z.array(createChannelSchema(this.linkBaseUrl)) + : ChannelListSchema /** * Gets all the user's workspaces. diff --git a/src/types/entities.test.ts b/src/types/entities.test.ts index 634ea2f..abd1c45 100644 --- a/src/types/entities.test.ts +++ b/src/types/entities.test.ts @@ -114,6 +114,22 @@ describe('entity url factories', () => { expect(createChannelSchema(base).parse(channel).url).toBe(`${base}/a/1/ch/${channelId}/`) }) + it('normalizes a trailing slash on the link base', () => { + const channel = { + id: channelId, + name: 'Engineering', + creator: 1, + public: true, + workspaceId: 1, + archived: false, + created: new Date(), + version: 1, + } + expect(createChannelSchema(`${base}/`).parse(channel).url).toBe( + `${base}/a/1/ch/${channelId}/`, + ) + }) + it('falls back to the default web app when no base is given', () => { const channel = { id: channelId, diff --git a/src/types/entities.ts b/src/types/entities.ts index 3df72fe..c3e520c 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -395,7 +395,7 @@ export const ConversationMessageSchema = createConversationMessageSchema() export type ConversationMessage = z.infer // InboxThread entity from API - returns full Thread objects with additional inbox metadata. -function createInboxThreadObjectSchema(linkBaseUrl?: string) { +export function createInboxThreadObjectSchema(linkBaseUrl?: string) { return z.object({ id: z.string(), title: z.string(), diff --git a/src/utils/url-helpers.ts b/src/utils/url-helpers.ts index 0576964..2a9fc5f 100644 --- a/src/utils/url-helpers.ts +++ b/src/utils/url-helpers.ts @@ -59,7 +59,9 @@ export function getCommsURL(params: CommsURLParams): string { * @param baseUrl - Optional base URL (defaults to 'https://comms.todoist.com') */ export function getFullCommsURL(params: CommsURLParams, baseUrl = COMMS_BASE_URL): string { - return `${baseUrl}${getCommsURL(params)}` + // Strip a trailing slash so links don't double up — `getCommsURL` paths start with '/'. + const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + return `${normalizedBase}${getCommsURL(params)}` } /** Returns the URL for a thread in a channel. */