Skip to content

fix(users): filter removed members from tw users by default#245

Open
lmjabreu wants to merge 2 commits into
mainfrom
lmjabreu/confident-lalande-769bec
Open

fix(users): filter removed members from tw users by default#245
lmjabreu wants to merge 2 commits into
mainfrom
lmjabreu/confident-lalande-769bec

Conversation

@lmjabreu
Copy link
Copy Markdown
Contributor

Summary

tw users was listing users who had been removed from the workspace. In Doist (ws:1585) this meant 554 entries returned for what is actually a 146-member workspace, polluting tw users [--json] output and silently breaking resolveUserRefs — any command that takes a name/email user ref (tw channel add, tw groups add-user, etc.) could resolve to a tombstoned user and then 403 from the backend with User N is not part of workspace.

Root cause: Twist's v4 workspace_users/get?id=<wsid> endpoint returns both active and removed members with a removed: boolean flag. The SDK's WorkspaceUserSchema already exposes that field — the CLI just wasn't reading it. The curated --json formatter also stripped it, so the staleness was invisible.

Reproducer (before the fix):

$ tw users 1585 --json --full | jq '[.[] | select(.removed)] | length'
408
$ tw users 1585 --json | jq '. | map(select(.id == 2342 or .id == 2343))'
[
  { "id": 2342, "name": "Test User1",  "userType": "GUEST" },
  { "id": 2343, "name": "Test User 2", "userType": "USER"  }
]
$ # Both above are removed members; adding them to a channel returns 403
$ # "User 2342 is not part of workspace 1585"

After the fix:

$ tw users 1585 --json | jq '. | length'
146
$ tw users 1585 --json --include-removed | jq '. | length'
554
$ tw users 1585 --include-removed --search "Test User1"
id:2342  Test User1 <test1@doist.io> [GUEST] [removed]

Changes

Layer File Notes
API helper src/lib/api.ts getWorkspaceUsers(workspaceId, { includeRemoved? }) filters removed === true by default. One filter, fixes tw users, resolveUserRefs (src/lib/refs.ts), and resolveNotifyIds (src/commands/thread/helpers.ts) in one shot.
Command src/commands/user.ts New --include-removed flag. Human output appends a red [removed] chip.
JSON src/lib/output.ts Added removed to USER_ESSENTIAL_FIELDS so callers can distinguish the two states without --full.
Tests src/commands/user.test.ts Two new tests: default omits removed users, flag passes through and chip renders.

getUserById-based callers (e.g. tw groups view) were already correct because they only fetch ids that the group's userIds list references — removed users aren't there.

Why a flag, not always filter

Audit/recovery cases occasionally do want the historical roster (e.g. "who used to be in here?"). --include-removed covers that without making the common case noisy.

Side discoveries

None. Scope was kept tight to the diagnosed bug.

Checks

  • npm run type-check
  • npm run lint:check
  • npm test — 653/653 (2 new)
  • Manual end-to-end against workspace 1585 (Doist) — counts above

Follow-up to side-discovery #3 in #244.

The Twist v4 workspace_users/get endpoint returns both active and
removed members, with a `removed: boolean` field that the CLI was
ignoring. In workspace 1585 this meant `tw users` listed 554 entries
when only 146 were actual members, and `resolveUserRefs` would
happily match a removed user by name/email, leading to confusing
HTTP 403 "User N is not part of workspace" errors from downstream
mutations like `tw channel add`.

Filter removed users in `getWorkspaceUsers` by default, and add an
`--include-removed` flag on `tw users` for the rare audit case
where you actually want to see them. When that flag is set, the
human-readable output appends a red [removed] chip; `removed` is
exposed in the curated JSON essentials so callers can distinguish
the two states.

Follow-up to side-discovery #3 in #244.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@doistbot doistbot requested a review from gnapse May 22, 2026 10:13
Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR thoughtfully addresses a significant issue with reference resolution by ensuring tw users filters out removed members by default, while introducing an --include-removed flag to access tombstoned users when needed. These changes greatly improve the reliability of workspace user management and correctly utilize the v4 API schema. A few minor adjustments are needed to document the new flag in the AI agent skill content, realign a misplaced test block, and strengthen the test suite to properly verify both the default filtering behavior and the updated JSON payload.

Share FeedbackReview Logs

Comment thread src/commands/user.ts
Comment thread src/commands/user.test.ts Outdated
Comment thread src/commands/user.test.ts Outdated
Comment thread src/commands/user.test.ts
Copy link
Copy Markdown
Collaborator

@scottlovegrove scottlovegrove left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether this should be the default of the SDK rather than this. The MCP probably has the same issue :/

I think I would prefer this to be the default of the SDK

scottlovegrove added a commit to Doist/twist-sdk-typescript that referenced this pull request May 22, 2026
## Summary
- `getWorkspaceUsers` now excludes users with `removed: true` by
default; pass `includeRemoved: true` to include them.
- The Twist API endpoint `/workspace_users/get` (server:
`workspace_users.py` `get(id)`) calls `workspaces.get_users(ws.id)`
without the model's `with_removed` arg, so it's hardcoded to
`with_removed=True` and exposes no override param. Filtering therefore
happens **client-side** in the SDK — matching what twist-web (Redux
selectors) and twist-cli already do.
- Applied to **both** execution paths: the awaited call filters in
`.then()`; the batch path uses a new optional `transform?: (data:
unknown) => T` hook on `BatchRequestDescriptor`, applied after schema
parse in `batch-builder.ts`. The batch descriptor now also carries
`z.array(WorkspaceUserSchema)`, so batch results are validated too.

Prompted by [twist-cli#245
review](Doist/twist-cli#245 (review))
— the default belongs in the SDK so cli/MCP can drop their own
filtering.

## Test plan
- [x] `getWorkspaceUsers` excludes removed users by default (awaited +
batch)
- [x] `includeRemoved: true` returns all users (awaited + batch)
- [x] No `include_removed`/`with_removed` query param is sent (proves
client-side filtering)
- [x] Full suite green (245 tests), `type-check`, `oxlint`, `oxfmt`
clean


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
doist-release-bot Bot added a commit to Doist/twist-sdk-typescript that referenced this pull request May 22, 2026
## [2.8.0](v2.7.1...v2.8.0) (2026-05-22)

### Features

* **workspace-users:** exclude removed users by default ([#138](#138)) ([6e4b93f](6e4b93f)), closes [twist-cli#245](Doist/twist-cli#245)
@scottlovegrove
Copy link
Copy Markdown
Collaborator

I'm wondering whether this should be the default of the SDK rather than this. The MCP probably has the same issue :/

I think I would prefer this to be the default of the SDK

This has been done and is available in 2.8.1 of the SDK. @lmjabreu ask your agent to pull in that newer SDK, and then we can tidy up this PR. The PR should still make the flag available, but it doesn't need to do any of the filtering.

Address PR feedback:

- Scott: twist-sdk 2.8.1 now filters removed users by default and
  exposes `includeRemoved` on `getWorkspaceUsers`. Drop the CLI-side
  `.filter(u => !u.removed)` and just forward `includeRemoved`
  through. Keeps the fix in one place so the MCP and any other
  SDK consumer benefit too.
- doistbot: extend `SKILL_CONTENT` with `--include-removed` so the
  AI agent skill stays in sync (re-ran `npm run sync:skill`).
- doistbot: move the new tests out of `describe('user --json')` into
  their own block, and strengthen assertions: text output verifies
  the chip lands on the removed row only; new test pins curated
  `--json --include-removed` output exposes `removed` without
  needing `--full`.
- doistbot: add `src/lib/api.test.ts` that mocks the SDK client and
  asserts `getWorkspaceUsers` forwards `includeRemoved` to the
  SDK — guards the contract the filtering now relies on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lmjabreu
Copy link
Copy Markdown
Contributor Author

@scottlovegrove done — bumped to @doist/twist-sdk@2.8.1 and reduced getWorkspaceUsers to a pass-through that forwards includeRemoved. Verified against ws:1585: default returns 146 active users (was 554 pre-fix), --include-removed returns 554 with the removed field intact in curated JSON. The four doistbot review threads are addressed inline + resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants