From f6b7ef84e7d29a42e6f6731d9ac01116692ac26a Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 22 May 2026 16:49:46 +0100 Subject: [PATCH 1/2] feat(workspace-users): exclude removed users by default `getWorkspaceUsers` now excludes users with `removed: true` by default; pass `includeRemoved: true` to include them. The Comms API's `workspace_users/get` always returns removed users with no override param, so the filtering happens client-side in the SDK. Ported from Doist/twist-sdk-typescript#138. Unlike the twist SDK, comms has no batch path, so the filter applies only to the awaited call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients/workspace-users-client.test.ts | 58 ++++++++++++++++++++++ src/clients/workspace-users-client.ts | 10 +++- src/types/requests.ts | 5 ++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/clients/workspace-users-client.test.ts diff --git a/src/clients/workspace-users-client.test.ts b/src/clients/workspace-users-client.test.ts new file mode 100644 index 0000000..d1cde47 --- /dev/null +++ b/src/clients/workspace-users-client.test.ts @@ -0,0 +1,58 @@ +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { apiUrl } from '../testUtils/msw-handlers' +import { server } from '../testUtils/msw-setup' +import { mockWorkspaceUser, TEST_API_TOKEN } from '../testUtils/test-defaults' +import { WorkspaceUsersClient } from './workspace-users-client' + +const removedWorkspaceUser = { + ...mockWorkspaceUser, + id: 2, + fullName: 'Removed User', + shortName: 'RU', + removed: true, +} + +describe('WorkspaceUsersClient', () => { + let client: WorkspaceUsersClient + + beforeEach(() => { + client = new WorkspaceUsersClient({ apiToken: TEST_API_TOKEN }) + }) + + describe('getWorkspaceUsers', () => { + it('excludes removed users by default and sends no server-side filter param', async () => { + server.use( + http.get(apiUrl('api/v1/workspace_users/get'), ({ request }) => { + const url = new URL(request.url) + expect(url.searchParams.get('id')).toBe('123') + expect(url.searchParams.has('include_removed')).toBe(false) + expect(url.searchParams.has('with_removed')).toBe(false) + return HttpResponse.json([mockWorkspaceUser, removedWorkspaceUser]) + }), + ) + + const result = await client.getWorkspaceUsers({ workspaceId: 123 }) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe(mockWorkspaceUser.id) + expect(result.some((user) => user.removed)).toBe(false) + }) + + it('includes removed users when includeRemoved is true', async () => { + server.use( + http.get(apiUrl('api/v1/workspace_users/get'), () => + HttpResponse.json([mockWorkspaceUser, removedWorkspaceUser]), + ), + ) + + const result = await client.getWorkspaceUsers({ + workspaceId: 123, + includeRemoved: true, + }) + + expect(result).toHaveLength(2) + expect(result.map((user) => user.id)).toEqual([1, 2]) + }) + }) +}) diff --git a/src/clients/workspace-users-client.ts b/src/clients/workspace-users-client.ts index 8b0c60e..5f832cd 100644 --- a/src/clients/workspace-users-client.ts +++ b/src/clients/workspace-users-client.ts @@ -18,9 +18,13 @@ export class WorkspaceUsersClient extends BaseClient { /** * Returns a list of workspace user objects for the given workspace id. * + * Removed users are excluded by default; set `args.includeRemoved` to `true` to include them. + * The Comms API always returns removed users, so the filtering happens client-side. + * * @param args - The arguments for getting workspace users. * @param args.workspaceId - The workspace ID. * @param args.archived - Optional flag to filter archived users. + * @param args.includeRemoved - Include users removed from the workspace. Defaults to `false`. * @returns An array of workspace user objects. * * @example @@ -30,6 +34,7 @@ export class WorkspaceUsersClient extends BaseClient { * ``` */ getWorkspaceUsers(args: GetWorkspaceUsersArgs): Promise { + const includeRemoved = args.includeRemoved ?? false return request({ httpMethod: 'GET', baseUri: this.getBaseUri(), @@ -37,7 +42,10 @@ export class WorkspaceUsersClient extends BaseClient { apiToken: this.apiToken, payload: { id: args.workspaceId, archived: args.archived }, customFetch: this.customFetch, - }).then((response) => response.data.map((user) => WorkspaceUserSchema.parse(user))) + }).then((response) => { + const users = response.data.map((user) => WorkspaceUserSchema.parse(user)) + return includeRemoved ? users : users.filter((user) => !user.removed) + }) } /** diff --git a/src/types/requests.ts b/src/types/requests.ts index ebd631b..2906f3a 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -396,6 +396,11 @@ export type RemoveGroupUsersArgs = { export type GetWorkspaceUsersArgs = { workspaceId: number archived?: boolean + /** + * Include users who have been removed from the workspace. Defaults to `false`. + * The Comms API always returns removed users, so the SDK filters them client-side. + */ + includeRemoved?: boolean } export type GetUserByIdArgs = { From d48e7722070241c7a479ab7cdba17702c622034d Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Fri, 22 May 2026 16:54:23 +0100 Subject: [PATCH 2/2] refactor: address doistbot review on workspace-user filtering - Filter removed users out of the raw response before Zod parsing, so discarded users don't incur validation overhead. - Assert no server-side filter param on the `includeRemoved: true` path too, locking in the client-side-only wire contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/clients/workspace-users-client.test.ts | 12 ++++++++---- src/clients/workspace-users-client.ts | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/clients/workspace-users-client.test.ts b/src/clients/workspace-users-client.test.ts index d1cde47..8d8393e 100644 --- a/src/clients/workspace-users-client.test.ts +++ b/src/clients/workspace-users-client.test.ts @@ -39,11 +39,15 @@ describe('WorkspaceUsersClient', () => { expect(result.some((user) => user.removed)).toBe(false) }) - it('includes removed users when includeRemoved is true', async () => { + it('includes removed users when includeRemoved is true and sends no server-side filter param', async () => { server.use( - http.get(apiUrl('api/v1/workspace_users/get'), () => - HttpResponse.json([mockWorkspaceUser, removedWorkspaceUser]), - ), + http.get(apiUrl('api/v1/workspace_users/get'), ({ request }) => { + const url = new URL(request.url) + expect(url.searchParams.get('id')).toBe('123') + expect(url.searchParams.has('include_removed')).toBe(false) + expect(url.searchParams.has('with_removed')).toBe(false) + return HttpResponse.json([mockWorkspaceUser, removedWorkspaceUser]) + }), ) const result = await client.getWorkspaceUsers({ diff --git a/src/clients/workspace-users-client.ts b/src/clients/workspace-users-client.ts index 5f832cd..b3908fd 100644 --- a/src/clients/workspace-users-client.ts +++ b/src/clients/workspace-users-client.ts @@ -43,8 +43,10 @@ export class WorkspaceUsersClient extends BaseClient { payload: { id: args.workspaceId, archived: args.archived }, customFetch: this.customFetch, }).then((response) => { - const users = response.data.map((user) => WorkspaceUserSchema.parse(user)) - return includeRemoved ? users : users.filter((user) => !user.removed) + const rawUsers = includeRemoved + ? response.data + : response.data.filter((user) => !user.removed) + return rawUsers.map((user) => WorkspaceUserSchema.parse(user)) }) }