diff --git a/Makefile b/Makefile
index 091d3ef3b..c918a9d59 100644
--- a/Makefile
+++ b/Makefile
@@ -168,7 +168,7 @@ sast-gosec:
# Requires outbound network access to https://vuln.go.dev.
sast-vuln:
@test -x "$(GOVULNCHECK)" || { echo "govulncheck not installed — run 'make tools'"; exit 1; }
- $(GOVULNCHECK) ./...
+ GOTOOLCHAIN=$(TOOLS_GO_TOOLCHAIN) $(GOVULNCHECK) ./...
# semgrep: rule-based SAST (Go security + security-audit rulesets).
# Requires outbound network access to the Semgrep registry on first run.
diff --git a/chat-frontend/src/api/fetchSidebarBuckets/index.ts b/chat-frontend/src/api/fetchSidebarBuckets/index.ts
index 9505b05b2..61c354ec7 100644
--- a/chat-frontend/src/api/fetchSidebarBuckets/index.ts
+++ b/chat-frontend/src/api/fetchSidebarBuckets/index.ts
@@ -132,7 +132,6 @@ function subToRoom(sub: DMSubscription, fallbackSiteId: string): Room {
appCount: 0,
lastMsgId: sub.lastMsgId ?? '',
lastMsgAt: sub.lastMsgAt ?? undefined,
- createdBy: '',
createdAt: '',
updatedAt: '',
}
diff --git a/chat-frontend/src/api/types.ts b/chat-frontend/src/api/types.ts
index f25010520..44ae09024 100644
--- a/chat-frontend/src/api/types.ts
+++ b/chat-frontend/src/api/types.ts
@@ -107,7 +107,6 @@ export interface Room {
id: string
name: string
type: RoomType
- createdBy: string
siteId: string
userCount: number
appCount: number
diff --git a/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx b/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx
index ae47a7659..4873aa6b3 100644
--- a/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx
+++ b/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx
@@ -90,19 +90,24 @@ export default function CreateRoomDialog({ onClose, onCreated }) {
{ treatAsSuccess: isDMExistsReply }
)
const roomId = sync.roomId
- // On the dedup branch the server only tells us the existing roomId, not
- // its type — could be either dm or botDM. Default to 'dm'; the canonical
- // type arrives shortly via subscription.update.
- const roomType = sync.roomType || (isDMExistsReply(sync) ? 'dm' : undefined)
// For DM/BotDM the name is empty; fall back to the counterpart account
// so the sidebar + header have something to show until the canonical
// name arrives via subscription.update.
const displayName = trimmedName || finalUsers[0] || ''
+
+ if (isDMExistsReply(sync)) {
+ // Dedup branch: server already confirmed the DM; skip the summaries-wait that can trip the 3s banner on a BUCKETS_LOADED race.
+ onCreated({ id: roomId, type: 'dm', siteId: user.siteId, name: displayName })
+ onClose()
+ return
+ }
+
+ // On the normal branch the server returns roomType explicitly.
// Request is done; flip loading off so Cancel becomes re-enabled
// during the subscription.update wait. The pendingRoom state marks
// the wait phase — see the two useEffects above for the resolution
// paths (summaries-match success vs timeout error).
- setPendingRoom({ id: roomId, type: roomType, siteId: user.siteId, name: displayName })
+ setPendingRoom({ id: roomId, type: sync.roomType, siteId: user.siteId, name: displayName })
} catch (err) {
setError(formatAsyncJobError(err))
} finally {
diff --git a/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx b/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx
index ad6418588..3e6657b65 100644
--- a/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx
+++ b/chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx
@@ -136,30 +136,30 @@ describe('CreateRoomDialog', () => {
)
})
- it('treats a "dm already exists" reply as success and navigates to the existing room', async () => {
- const requestWithAsyncResult = vi.fn().mockResolvedValue({
- sync: { error: 'dm already exists', roomId: 'r-existing' },
- async: null,
- })
- useNats.mockReturnValue({
- user: { account: 'alice', siteId: 'site-A' },
- request: vi.fn(),
- requestWithAsyncResult,
- })
- useRoomSummaries.mockReturnValue({ summaries: DEFAULT_SUMMARIES })
- const onCreated = vi.fn()
- const onClose = vi.fn()
- render()
- fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
- fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
- fireEvent.click(screen.getByRole('button', { name: /Create/i }))
- await waitFor(() => expect(onCreated).toHaveBeenCalledWith(
- expect.objectContaining({ id: 'r-existing', type: 'dm' })
- ))
- await waitFor(() => expect(onClose).toHaveBeenCalled())
- expect(requestWithAsyncResult.mock.calls[0][2]).toMatchObject({
- treatAsSuccess: expect.any(Function),
- })
+ it('navigates directly to the existing room on dm-already-exists reply (no summaries-wait)', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ try {
+ const { onCreated, onClose } = setup({
+ summaries: [],
+ requestWithAsyncResult: vi.fn().mockResolvedValue({
+ sync: { error: 'dm already exists', roomId: 'r-existing' },
+ async: null,
+ }),
+ })
+
+ fireEvent.change(screen.getByLabelText(/Users/i), { target: { value: 'bob' } })
+ fireEvent.keyDown(screen.getByLabelText(/Users/i), { key: 'Enter' })
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+
+ await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1))
+ expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: 'r-existing', type: 'dm' }))
+ expect(onClose).toHaveBeenCalledTimes(1)
+
+ await vi.advanceTimersByTimeAsync(3500)
+ expect(screen.queryByText(/taking longer than expected/i)).toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
})
it('auto-flushes typed-but-not-Entered text into the request payload', async () => {
diff --git a/docker-local/cassandra/init/10-table-messages_by_room.cql b/docker-local/cassandra/init/10-table-messages_by_room.cql
index 93d0a437d..7701fd967 100644
--- a/docker-local/cassandra/init/10-table-messages_by_room.cql
+++ b/docker-local/cassandra/init/10-table-messages_by_room.cql
@@ -5,7 +5,6 @@ CREATE TABLE IF NOT EXISTS chat.messages_by_room (
message_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/docker-local/cassandra/init/11-table-thread_messages_by_room.cql b/docker-local/cassandra/init/11-table-thread_messages_by_room.cql
index 9186e5289..3335141d4 100644
--- a/docker-local/cassandra/init/11-table-thread_messages_by_room.cql
+++ b/docker-local/cassandra/init/11-table-thread_messages_by_room.cql
@@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS chat.thread_messages_by_room (
message_id TEXT,
thread_parent_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/docker-local/cassandra/init/12-table-pinned_messages_by_room.cql b/docker-local/cassandra/init/12-table-pinned_messages_by_room.cql
index 939ea557b..bcfaed67e 100644
--- a/docker-local/cassandra/init/12-table-pinned_messages_by_room.cql
+++ b/docker-local/cassandra/init/12-table-pinned_messages_by_room.cql
@@ -3,7 +3,6 @@ CREATE TABLE IF NOT EXISTS chat.pinned_messages_by_room (
created_at TIMESTAMP, // = pinnedAt
message_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/docker-local/cassandra/init/13-table-messages_by_id.cql b/docker-local/cassandra/init/13-table-messages_by_id.cql
index ff3b4a64a..902be8ec0 100644
--- a/docker-local/cassandra/init/13-table-messages_by_id.cql
+++ b/docker-local/cassandra/init/13-table-messages_by_id.cql
@@ -3,7 +3,6 @@ CREATE TABLE IF NOT EXISTS chat.messages_by_id (
room_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/docker-local/compose.deps.yaml b/docker-local/compose.deps.yaml
index b28dc3f27..ff319544b 100644
--- a/docker-local/compose.deps.yaml
+++ b/docker-local/compose.deps.yaml
@@ -28,7 +28,9 @@ services:
- chat-local
mongodb:
- image: mongo:8
+ # Patch-pinned to match pkg/testutil/testimages/testimages.go so local dev
+ # tracks the same image testcontainers uses.
+ image: mongo:8.2.9
container_name: chat-local-mongodb
ports:
- "27017:27017"
@@ -151,7 +153,8 @@ services:
# --cluster-enabled, waits for it to accept connections, then assigns all
# 16384 hash slots so it forms a valid one-node cluster.
valkey:
- image: valkey/valkey:8-alpine
+ # Patch-pinned (same rationale as mongodb above).
+ image: valkey/valkey:8.1.7-alpine
container_name: chat-local-valkey
ports:
- "6379:6379"
diff --git a/docs/cassandra_message_model.md b/docs/cassandra_message_model.md
index a30c4a925..a6a3ae7cf 100644
--- a/docs/cassandra_message_model.md
+++ b/docs/cassandra_message_model.md
@@ -76,7 +76,6 @@ CREATE TABLE IF NOT EXISTS messages_by_room(
message_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
@@ -109,7 +108,6 @@ CREATE TABLE IF NOT EXISTS thread_messages_by_room(
message_id TEXT,
thread_parent_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
@@ -135,7 +133,6 @@ CREATE TABLE IF NOT EXISTS pinned_messages_by_room(
created_at TIMESTAMP, // =pinnedAt
message_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
@@ -162,7 +159,6 @@ CREATE TABLE IF NOT EXISTS messages_by_id(
room_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/docs/client-api.md b/docs/client-api.md
index 62d7c31fe..52cb96dbb 100644
--- a/docs/client-api.md
+++ b/docs/client-api.md
@@ -196,18 +196,22 @@ When the auth-service is started with `DEV_MODE=true`, the request body schema i
|--------------------|----------|----------|-------|
| `name` | string | yes | Room name. |
| `type` | string | yes | One of `channel`, `dm`, `botDM`, `discussion`. |
-| `createdBy` | string | yes | Internal user ID of the creator. |
-| `createdByAccount` | string | yes | Account name of the creator. Used for the owner subscription. |
| `siteId` | string | yes | The site that will own this room. |
| `members` | string[] | no | Required exactly **one** entry when `type=dm` (the other user's ID); ignored otherwise. |
+| `users` | string[] | no | `channel` only. Internal user IDs (or accounts) to enroll as members at creation time. Rejected with `"user not found"` if any entry has no matching user document. |
+| `orgs` | string[] | no | `channel` only. Org IDs to enroll (expanded server-side to all org members). Rejected with `"invalid org"` if any entry matches zero users. |
+| `channels` | array | no | `channel` only. Other channels whose members should be copied in. Each entry is `{ "roomId": string, "siteId": string }`. |
+
+The creator's account is taken from the `{account}` segment of the subject (`chat.user.{account}.request.rooms.create`); the client does not pass it in the body.
```json
{
"name": "engineering-announcements",
"type": "channel",
- "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
- "createdByAccount": "alice",
- "siteId": "siteA"
+ "siteId": "siteA",
+ "users": ["bob"],
+ "orgs": ["org-eng"],
+ "channels": []
}
```
@@ -220,9 +224,8 @@ The created `Room` object.
| `id` | string | Room ID. 17-char base62 for channels; sorted concat of two accounts for DMs. |
| `name` | string | |
| `type` | string | Same values as request. |
-| `createdBy` | string | |
| `siteId` | string | |
-| `userCount` | number | `1` immediately after creation (the owner). |
+| `userCount` | number | `1` for owner-only creates; higher when initial members were enrolled at creation via `users` / `orgs` / `channels`. |
| `lastMsgAt` | string | Optional. RFC 3339 timestamp; absent until first message. |
| `lastMsgId` | string | Empty until first message. |
| `lastMentionAllAt` | string | Optional. RFC 3339 timestamp. |
@@ -238,7 +241,6 @@ The created `Room` object.
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
- "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 1,
"lastMsgId": "",
@@ -249,7 +251,7 @@ The created `Room` object.
##### Error response
-See [Error envelope](#6-error-envelope-reference).
+See [Error envelope](#6-error-envelope-reference). Channel creates also reject any `orgs` entry that matches zero users with `"invalid org"` and any `users` entry without a matching user document with `"user not found"` (same gates as Add Members — phantom org IDs or accounts do not create a room).
```json
{ "error": "DM requires exactly one other member, got 0" }
@@ -257,7 +259,7 @@ See [Error envelope](#6-error-envelope-reference).
##### Triggered events — success path
-`None — reply only.` Member additions are a separate RPC (Add Members); creating a room only enrolls the owner.
+`None — reply only.` Creation enrolls the owner (from the subject's `{account}`) plus any members supplied via `users` / `orgs` / `channels` per the request schema above. Adding members to an existing room is a separate RPC (Add Members).
##### Triggered events — error path
@@ -291,7 +293,6 @@ Empty. Send `{}` or no payload.
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
- "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 12,
"lastMsgAt": "2026-05-06T07:55:00Z",
@@ -341,7 +342,6 @@ A single `Room` object. See [Create Room](#create-room) for the `Room` schema.
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
- "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 12,
"lastMsgAt": "2026-05-06T07:55:00Z",
@@ -411,7 +411,7 @@ The fields `requesterId`, `requesterAccount`, and `timestamp` on the Go `AddMemb
##### Error response
-See [Error envelope](#6-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner).
+See [Error envelope](#6-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner). Any `orgs` entry that matches zero users (no user with `sectId == orgId` or `deptId == orgId`) is rejected with `"invalid org"`, and any `users` entry that has no matching user document is rejected with `"user not found"` — in both cases the request is not queued and no members are added.
```json
{ "error": "room is at maximum capacity (200): cannot add 5 members to room with 198 existing" }
@@ -864,7 +864,7 @@ See [Error envelope](#6-error-envelope-reference). Common errors:
**Subject:** `chat.user.{account}.request.orgs.{orgID}.members`
**Reply subject:** auto-generated `_INBOX.>` (NATS request/reply)
-The org ID is the second-to-last subject segment — there is no request body.
+The org ID is the second-to-last subject segment — there is no request body. `orgID` matches a user's `sectId` OR `deptId`; the response includes every user whose either field equals `orgID`. This mirrors the dept-aware org membership pipelines on the server side (a room may be added by sect-level or dept-level org and either form resolves through this endpoint).
##### Request body
@@ -935,7 +935,6 @@ Used by every history-service method that returns messages. Mirrors the Cassandr
| `messageId` | string | 17- or 20-char base62. |
| `sender` | object | A `Participant` — see below. |
| `msg` | string | The message body. |
-| `targetUser` | object | Optional. `Participant` — set for direct/system messages addressed to a specific user. |
| `mentions` | array | Optional. |
| `attachments` | string[] | Optional. Each entry is base64-encoded bytes. |
| `file` | object | Optional. `{id, name, type}`. |
diff --git a/docs/superpowers/plans/2026-05-19-member-add-improvements-plan.md b/docs/superpowers/plans/2026-05-19-member-add-improvements-plan.md
new file mode 100644
index 000000000..508724b43
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-member-add-improvements-plan.md
@@ -0,0 +1,2330 @@
+# Member-Add Improvements Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the three changes specified in `docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md`: (Part 1) fix the silent no-op when an already-org-subscribed user is added individually; (Part 2) accept `deptId` values inside the `orgs` field with prefer-dept-on-overlap display; (Part 3) frontend dedup-reply navigates directly without entering the wait-for-summaries state.
+
+**Architecture:** Backend pipeline + worker changes in Go (`pkg/pipelines`, `room-worker`, `room-service`); frontend change in React (`chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog`). All new aggregations index-covered via existing or one new MongoDB index `(deptId, account)` on `users`. TDD throughout: failing test → implementation → green → commit per task.
+
+**Tech Stack:** Go 1.25, MongoDB driver v2, mockgen for unit mocks, testcontainers-go for integration tests, Gin (room-service HTTP), nats.go + JetStream (workers). Frontend: React + vitest + @testing-library/react.
+
+---
+
+## File map
+
+**Modify:**
+- `pkg/pipelines/member.go` — add `GetCapacityCheckPipeline` + `GetAddMemberCandidatesPipeline`, extend $or to match `deptId`.
+- `pkg/model/user.go` — add `SectTCName`, `DeptID`, `DeptName`, `DeptTCName` fields.
+- `pkg/model/model_test.go` — roundtrip test covers new fields.
+- `room-worker/store.go` — add `AddMemberCandidate` type + `ListAddMemberCandidates` method; extend `OrgMemberStatus`; remove `ListNewMembers`.
+- `room-worker/store_mongo.go` — implementations; update `GetOrgMembersWithIndividualStatus` to use `member.id` lookup + new fields.
+- `room-worker/handler.go` — `processAddMembers` rewrite around new method; `processRemoveOrg` two-pass dept-first tiebreak with `displayOrg` formatting; drop "missing SectName" permanent error.
+- `room-worker/handler_test.go` — table-driven cases for the new branches.
+- `room-worker/integration_test.go` — integration cases for the org→individual upgrade and dept-matched org-remove.
+- `room-worker/sysmsg.go` — extract `combineWithFallback`, refactor `displayName`, add `displayOrg`, update `formatRemovedOrg` signature.
+- `room-worker/sysmsg_test.go` — new test file for the helpers.
+- `room-worker/mock_store_test.go` — regenerated.
+- `room-service/store_mongo.go` — swap `CountNewMembers` to `GetCapacityCheckPipeline`; add `(deptId, account)` index in `EnsureIndexes`; update enrichment `_orgMatch` for prefer-dept + orgId fallback.
+- `room-service/integration_test.go` — capacity-regression + enrichment-dept cases.
+- `chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx` — short-circuit dedup branch.
+- `chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx` — rewrite the existing dedup test + new race test.
+
+**No change:**
+- `pkg/model/member.go` — `MemberRemoved.SectName` field name stays; semantics broaden in worker.
+- `room-service/handler.go`, `room-service/store.go` — public surface unchanged.
+- `room_members` collection schema unchanged; `member.type` stays `"org"`.
+
+---
+
+# Part 1 — Backend bug fix: org→individual silent no-op
+
+## Task 1: Add `GetCapacityCheckPipeline` and `GetAddMemberCandidatesPipeline`
+
+**Files:**
+- Modify: `pkg/pipelines/member.go`
+
+**Approach:** Add two new pipeline builders next to the existing `GetNewMembersPipeline`. Share a private `matchCandidates` helper for the common `$match` stage. No tests in this task — the pipelines are data builders, verified via the integration test in Task 2.
+
+- [ ] **Step 1: Add the helper + new pipeline functions to `pkg/pipelines/member.go`**
+
+Append after the existing `GetNewMembersPipeline`:
+
+```go
+// matchCandidates: $match users by (sectId|deptId IN orgIDs) OR (account IN directAccounts), bot/excludeAccount filtered.
+func matchCandidates(orgIDs, directAccounts []string, excludeAccount string) bson.M {
+ orFilter := bson.A{}
+ if len(orgIDs) > 0 {
+ orFilter = append(orFilter, bson.M{"sectId": bson.M{"$in": orgIDs}})
+ }
+ if len(directAccounts) > 0 {
+ orFilter = append(orFilter, bson.M{"account": bson.M{"$in": directAccounts}})
+ }
+ accountFilter := bson.M{"$not": bson.Regex{Pattern: `(\.bot$|^p_)`, Options: ""}}
+ if excludeAccount != "" {
+ accountFilter["$ne"] = excludeAccount
+ }
+ return bson.M{"$match": bson.M{"$or": orFilter, "account": accountFilter}}
+}
+
+// GetCapacityCheckPipeline counts net-new subscriptions for (orgIDs, directAccounts) in roomID; caller appends $count.
+func GetCapacityCheckPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) bson.A {
+ if roomID == "" {
+ panic("GetCapacityCheckPipeline: roomID required")
+ }
+ return bson.A{
+ matchCandidates(orgIDs, directAccounts, excludeAccount),
+ bson.M{"$lookup": bson.M{
+ "from": "subscriptions",
+ "let": bson.M{"acct": "$account"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$roomId", roomID}},
+ bson.M{"$eq": bson.A{"$u.account", "$$acct"}},
+ }}}},
+ bson.M{"$limit": 1},
+ },
+ "as": "_sub",
+ }},
+ bson.M{"$match": bson.M{"_sub": bson.M{"$eq": bson.A{}}}},
+ }
+}
+
+// GetAddMemberCandidatesPipeline returns per-candidate {account, hasSubscription, hasIndividualRoomMember} for the worker.
+func GetAddMemberCandidatesPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) bson.A {
+ if roomID == "" {
+ panic("GetAddMemberCandidatesPipeline: roomID required")
+ }
+ return bson.A{
+ matchCandidates(orgIDs, directAccounts, excludeAccount),
+ bson.M{"$lookup": bson.M{
+ "from": "subscriptions",
+ "let": bson.M{"acct": "$account"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$roomId", roomID}},
+ bson.M{"$eq": bson.A{"$u.account", "$$acct"}},
+ }}}},
+ bson.M{"$limit": 1},
+ },
+ "as": "_sub",
+ }},
+ bson.M{"$lookup": bson.M{
+ "from": "room_members",
+ "let": bson.M{"uid": "$_id"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$rid", roomID}},
+ bson.M{"$eq": bson.A{"$member.type", "individual"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$uid"}},
+ }}}},
+ bson.M{"$limit": 1},
+ },
+ "as": "_irm",
+ }},
+ bson.M{"$project": bson.M{
+ "_id": 0,
+ "account": "$account",
+ "hasSubscription": bson.M{"$gt": bson.A{bson.M{"$size": "$_sub"}, 0}},
+ "hasIndividualRoomMember": bson.M{"$gt": bson.A{bson.M{"$size": "$_irm"}, 0}},
+ }},
+ }
+}
+```
+
+- [ ] **Step 2: Verify compilation**
+
+```sh
+make build SERVICE=room-worker
+make build SERVICE=room-service
+```
+
+Both should succeed. No tests are run yet — they fail at the next task.
+
+- [ ] **Step 3: Commit**
+
+```sh
+git add pkg/pipelines/member.go
+git commit -m "feat(pipelines): add GetCapacityCheckPipeline + GetAddMemberCandidatesPipeline"
+```
+
+---
+
+## Task 2: Add `AddMemberCandidate` type + `ListAddMemberCandidates` store method (room-worker)
+
+**Files:**
+- Modify: `room-worker/store.go`, `room-worker/store_mongo.go`
+- Test: `room-worker/integration_test.go`
+- Regenerate: `room-worker/mock_store_test.go`
+
+**Approach:** Add the new method to the store interface, write an integration test against testcontainers MongoDB that verifies the per-flag output for the four key states (truly new, has-sub-no-IRM, no-sub-has-IRM impossible state, has-sub-has-IRM), implement the method backed by `GetAddMemberCandidatesPipeline`.
+
+- [ ] **Step 1: Add the `AddMemberCandidate` type and interface method to `room-worker/store.go`**
+
+Insert after `OrgMemberStatus` (around line 33):
+
+```go
+// AddMemberCandidate is one element returned by ListAddMemberCandidates.
+type AddMemberCandidate struct {
+ Account string `bson:"account"`
+ HasSubscription bool `bson:"hasSubscription"`
+ HasIndividualRoomMember bool `bson:"hasIndividualRoomMember"`
+}
+```
+
+Add to the `SubscriptionStore` interface (after `ListNewMembers` on line 74):
+
+```go
+ // ListAddMemberCandidates: per-user {hasSub, hasIndividualRow} flags so the worker splits into needSub vs needIRM (org→individual upgrade).
+ ListAddMemberCandidates(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]AddMemberCandidate, error)
+```
+
+- [ ] **Step 2: Regenerate mocks**
+
+```sh
+make generate SERVICE=room-worker
+```
+
+Expect: `room-worker/mock_store_test.go` updated; no unrelated diff. Verify with `git diff --stat room-worker/mock_store_test.go`.
+
+- [ ] **Step 3: Write the failing integration test**
+
+Add to `room-worker/integration_test.go` (find the section near other `Test*MongoStore_*` integration tests; preserve `//go:build integration` placement):
+
+```go
+func TestMongoStore_ListAddMemberCandidates_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ // Seed: alice (new), bob (sub only — bug scenario), carol (sub+IRM), dave (bot, excluded).
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_carol", Account: "carol", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_dave", Account: "dave.bot", SectID: "org-eng", SiteID: "site-a"})
+
+ const roomID = "room-1"
+ // bob: subscription only (the bug scenario — added via org earlier).
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_bob", Account: "bob"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ // carol: subscription + individual room_member.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_carol", Account: "carol"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "u_carol", Type: model.RoomMemberIndividual, Account: "carol"},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListAddMemberCandidates(ctx, []string{"org-eng"}, nil, roomID)
+ require.NoError(t, err)
+
+ byAccount := map[string]AddMemberCandidate{}
+ for _, c := range got {
+ byAccount[c.Account] = c
+ }
+ require.Len(t, byAccount, 3, "bot dave.bot must be excluded")
+ assert.Equal(t, AddMemberCandidate{Account: "alice", HasSubscription: false, HasIndividualRoomMember: false}, byAccount["alice"])
+ assert.Equal(t, AddMemberCandidate{Account: "bob", HasSubscription: true, HasIndividualRoomMember: false}, byAccount["bob"], "bug scenario: sub exists, IRM does not")
+ assert.Equal(t, AddMemberCandidate{Account: "carol", HasSubscription: true, HasIndividualRoomMember: true}, byAccount["carol"])
+}
+```
+
+Note: the existing file uses inline `db.Collection("room_members").InsertOne(ctx, ...)` for room_members inserts. Replace the `mustInsertRoomMember(...)` call above with that pattern (or add a small `mustInsertRoomMember(t, db, rm)` helper next to `mustInsertSub` at line ~422 modeled on `mustInsertSub`).
+
+- [ ] **Step 4: Run the test and verify it fails with "method not defined"**
+
+```sh
+make test-integration SERVICE=room-worker
+```
+
+Expected: compile error or panic from missing `ListAddMemberCandidates` on `MongoStore`.
+
+- [ ] **Step 5: Implement `ListAddMemberCandidates` in `room-worker/store_mongo.go`**
+
+Insert next to the existing `ListNewMembers` (around line 387):
+
+```go
+func (s *MongoStore) ListAddMemberCandidates(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]AddMemberCandidate, error) {
+ if len(orgIDs) == 0 && len(directAccounts) == 0 {
+ return nil, nil
+ }
+ pipeline := pipelines.GetAddMemberCandidatesPipeline(orgIDs, directAccounts, roomID, "")
+ cursor, err := s.users.Aggregate(ctx, pipeline)
+ if err != nil {
+ return nil, fmt.Errorf("aggregate add-member candidates: %w", err)
+ }
+ defer cursor.Close(ctx)
+ var out []AddMemberCandidate
+ if err := cursor.All(ctx, &out); err != nil {
+ return nil, fmt.Errorf("decode add-member candidates: %w", err)
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 6: Run the integration test, verify it passes**
+
+```
+make test-integration SERVICE=room-worker
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Run unit tests to confirm no regressions**
+
+```
+make test SERVICE=room-worker
+```
+
+Expected: PASS (mocks regenerated; nothing in `handler_test.go` references the new method yet).
+
+- [ ] **Step 8: Commit**
+
+```
+git add room-worker/store.go room-worker/store_mongo.go room-worker/integration_test.go room-worker/mock_store_test.go
+git commit -m "feat(room-worker): add ListAddMemberCandidates with per-candidate flags"
+```
+
+---
+
+## Task 3: Rewrite `processAddMembers` in `room-worker/handler.go`
+
+**Files:**
+- Modify: `room-worker/handler.go`, `room-worker/handler_test.go`
+- Existing: `room-worker/integration_test.go`
+
+**Approach:** Replace the `ListNewMembers` call with `ListAddMemberCandidates`. Split candidates into `needSub` and `needIndividualRoomMember`. Early-return only when both sets are empty AND `req.Orgs` is empty. The backfill loop's "already processed" set becomes `needSub ∪ needIndividualRoomMember`. Existing happy-path tests must continue to pass; add the bug-scenario test first.
+
+- [ ] **Step 1: Add the failing handler test for the org→individual upgrade**
+
+Add to `room-worker/handler_test.go` (find existing `TestHandler_ProcessAddMembers_*` cases as the model):
+
+```go
+func TestHandler_ProcessAddMembers_OrgToIndividualUpgrade(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+ keyStore := NewMockRoomKeyStore(ctrl)
+ h := newTestHandler(store, keyStore, "site-a")
+
+ roomID := "room-1"
+ requestID := idgen.GenerateRequestID()
+ ctx := natsutil.WithRequestID(context.Background(), requestID)
+
+ store.EXPECT().GetRoom(ctx, roomID).Return(&model.Room{
+ ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "Room 1",
+ }, nil)
+ // Alice has a subscription (added earlier via org) but no individual row.
+ store.EXPECT().ListAddMemberCandidates(ctx, []string{}, []string{"alice"}, roomID).Return([]AddMemberCandidate{
+ {Account: "alice", HasSubscription: true, HasIndividualRoomMember: false},
+ }, nil)
+ store.EXPECT().FindUsersByAccounts(ctx, []string{"alice"}).Return([]model.User{
+ {ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱丽丝", SiteID: "site-a"},
+ }, nil)
+ store.EXPECT().GetUser(ctx, "owner").Return(&model.User{
+ ID: "u_owner", Account: "owner", EngName: "Owner", ChineseName: "拥有者", SiteID: "site-a",
+ }, nil)
+ // No BulkCreateSubscriptions (alice already subscribed); BulkCreateRoomMembers with one individual row.
+ store.EXPECT().HasOrgRoomMembers(ctx, roomID).Return(true, nil)
+ store.EXPECT().BulkCreateRoomMembers(ctx, gomock.Any()).DoAndReturn(func(_ context.Context, members []*model.RoomMember) error {
+ require.Len(t, members, 1)
+ assert.Equal(t, model.RoomMemberIndividual, members[0].Member.Type)
+ assert.Equal(t, "alice", members[0].Member.Account)
+ return nil
+ })
+ store.EXPECT().ReconcileMemberCounts(ctx, roomID).Return(nil)
+ publishMock := newPublishRecorder(t, h)
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, Users: []string{"alice"}, RequesterAccount: "owner", RequesterID: "u_owner",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ err := h.processAddMembers(ctx, data)
+ require.NoError(t, err)
+
+ // Confirm the upgrade path still emits its events: a subscription.update for alice and the canonical sys-message for "added".
+ publishMock.AssertSubjectFired(t, subject.SubscriptionUpdate("alice"))
+ publishMock.AssertSubjectFired(t, subject.MsgCanonicalCreated("site-a"))
+}
+```
+
+`newPublishRecorder` returns a recorder with the same `publish` signature used by the handler, exposing helpers like `AssertSubjectFired(t, subject)` to verify publishes. Model on the existing publish capture pattern at `room-worker/integration_test.go:626, 700` if the recorder helper doesn't exist yet — add it next to that pattern.
+
+- [ ] **Step 2: Run the test, verify it fails**
+
+```
+make test SERVICE=room-worker -run TestHandler_ProcessAddMembers_OrgToIndividualUpgrade
+```
+
+Expected: FAIL — `ListAddMemberCandidates` is mocked but `processAddMembers` still calls `ListNewMembers`.
+
+- [ ] **Step 3: Rewrite `processAddMembers` in `room-worker/handler.go`**
+
+Replace the block between line 710 (`accounts, err := h.store.ListNewMembers(...)`) and line 832 (end of the org `room_members` loop) with:
+
+```go
+ // Resolve candidates with per-flag membership status.
+ candidates, err := h.store.ListAddMemberCandidates(ctx, req.Orgs, req.Users, req.RoomID)
+ if err != nil {
+ return fmt.Errorf("list add-member candidates: %w", err)
+ }
+
+ // Fail closed: defaulting hadOrgsBefore=false on error would trigger spurious first-org backfill.
+ hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID)
+ if err != nil {
+ return fmt.Errorf("check existing org room members: %w", err)
+ }
+ writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore
+
+ allowedIndividual := make(map[string]struct{}, len(req.Users))
+ for _, acc := range req.Users {
+ allowedIndividual[acc] = struct{}{}
+ }
+
+ // needSub = no sub yet; needIRM = no individual row yet (writeIndividuals-gated, req.Users only).
+ var needSub []AddMemberCandidate
+ var needIRM []AddMemberCandidate
+ for _, c := range candidates {
+ if !c.HasSubscription {
+ needSub = append(needSub, c)
+ }
+ if writeIndividuals && !c.HasIndividualRoomMember {
+ if _, ok := allowedIndividual[c.Account]; ok {
+ needIRM = append(needIRM, c)
+ }
+ }
+ }
+
+ // Nothing to write: no new subs, no individual upgrades, no org rows.
+ if len(needSub) == 0 && len(needIRM) == 0 && len(req.Orgs) == 0 {
+ return nil
+ }
+
+ // Build the lookup-account set: anyone whose sub or individual row we'll write.
+ lookupAccounts := make([]string, 0, len(needSub)+len(needIRM))
+ seen := make(map[string]struct{}, len(needSub)+len(needIRM))
+ for _, c := range needSub {
+ if _, ok := seen[c.Account]; !ok {
+ lookupAccounts = append(lookupAccounts, c.Account)
+ seen[c.Account] = struct{}{}
+ }
+ }
+ for _, c := range needIRM {
+ if _, ok := seen[c.Account]; !ok {
+ lookupAccounts = append(lookupAccounts, c.Account)
+ seen[c.Account] = struct{}{}
+ }
+ }
+
+ var userMap map[string]model.User
+ if len(lookupAccounts) > 0 {
+ users, err := h.store.FindUsersByAccounts(ctx, lookupAccounts)
+ if err != nil {
+ return fmt.Errorf("find users by accounts: %w", err)
+ }
+ userMap = make(map[string]model.User, len(users))
+ for i := range users {
+ userMap[users[i].Account] = users[i]
+ }
+ for _, acc := range lookupAccounts {
+ if _, ok := userMap[acc]; !ok {
+ return newPermanent("user %s not found in room.member.add (room %s)", acc, req.RoomID)
+ }
+ }
+ }
+
+ requester, err := h.store.GetUser(ctx, req.RequesterAccount)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ return newPermanent("requester %s not found (room %s)", req.RequesterAccount, req.RoomID)
+ }
+ return fmt.Errorf("get requester: %w", err)
+ }
+
+ acceptedAt := time.UnixMilli(req.Timestamp).UTC()
+ now := time.Now().UTC()
+
+ // Build subs only for needSub.
+ subs := make([]*model.Subscription, 0, len(needSub))
+ actualAccounts := make([]string, 0, len(needSub))
+ for _, c := range needSub {
+ user := userMap[c.Account]
+ sub := &model.Subscription{
+ ID: idgen.GenerateUUIDv7(),
+ User: model.SubscriptionUser{ID: user.ID, Account: user.Account},
+ RoomID: req.RoomID,
+ Name: room.Name,
+ RoomType: model.RoomTypeChannel,
+ SiteID: room.SiteID,
+ Roles: []model.Role{model.RoleMember},
+ JoinedAt: acceptedAt,
+ }
+ if ms := historySharedSincePtr(req.History, req.Timestamp, req.RoomID); ms != nil {
+ t := time.UnixMilli(*ms).UTC()
+ sub.HistorySharedSince = &t
+ }
+ subs = append(subs, sub)
+ actualAccounts = append(actualAccounts, user.Account)
+ }
+
+ if len(subs) > 0 {
+ if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil {
+ return fmt.Errorf("bulk create subscriptions: %w", err)
+ }
+ }
+
+ // Build room_members: individuals (needIRM) + orgs (req.Orgs).
+ roomMembers := make([]*model.RoomMember, 0, len(needIRM)+len(req.Orgs))
+ processedAccounts := make(map[string]struct{}, len(needSub)+len(needIRM))
+ for _, c := range needSub {
+ processedAccounts[c.Account] = struct{}{}
+ }
+ for _, c := range needIRM {
+ processedAccounts[c.Account] = struct{}{}
+ user := userMap[c.Account]
+ roomMembers = append(roomMembers, &model.RoomMember{
+ ID: idgen.GenerateUUIDv7(),
+ RoomID: req.RoomID,
+ Ts: acceptedAt,
+ Member: model.RoomMemberEntry{
+ ID: user.ID,
+ Type: model.RoomMemberIndividual,
+ Account: user.Account,
+ },
+ })
+ }
+ for _, org := range req.Orgs {
+ roomMembers = append(roomMembers, &model.RoomMember{
+ ID: idgen.GenerateUUIDv7(),
+ RoomID: req.RoomID,
+ Ts: acceptedAt,
+ Member: model.RoomMemberEntry{ID: org, Type: model.RoomMemberOrg},
+ })
+ }
+```
+
+Then update the backfill block (currently at lines 836-867) to use `processedAccounts` instead of `resolvedAccountSet`:
+
+```go
+ if len(req.Orgs) > 0 && !hadOrgsBefore {
+ existingAccounts, err := h.store.GetSubscriptionAccounts(ctx, req.RoomID)
+ if err != nil {
+ slog.Warn("get subscription accounts for backfill failed", "error", err)
+ } else {
+ var backfillAccounts []string
+ for _, account := range existingAccounts {
+ if _, processed := processedAccounts[account]; !processed {
+ backfillAccounts = append(backfillAccounts, account)
+ }
+ }
+ if len(backfillAccounts) > 0 {
+ backfillUsers, err := h.store.FindUsersByAccounts(ctx, backfillAccounts)
+ if err != nil {
+ slog.Warn("find users for backfill failed", "error", err)
+ } else {
+ for i := range backfillUsers {
+ roomMembers = append(roomMembers, &model.RoomMember{
+ ID: idgen.GenerateUUIDv7(),
+ RoomID: req.RoomID,
+ Ts: acceptedAt,
+ Member: model.RoomMemberEntry{
+ ID: backfillUsers[i].ID,
+ Type: model.RoomMemberIndividual,
+ Account: backfillUsers[i].Account,
+ },
+ })
+ }
+ }
+ }
+ }
+ }
+```
+
+The rest of the function (`BulkCreateRoomMembers`, `ReconcileMemberCounts`, event publish, sys-msg publish) is unchanged.
+
+Also remove the unused `resolvedAccountSet` and `resolvedAccountSet[user.Account] = struct{}{}` line if you spot it in the deleted block.
+
+- [ ] **Step 4: Run the new test and verify it passes**
+
+```
+make test SERVICE=room-worker -run TestHandler_ProcessAddMembers_OrgToIndividualUpgrade
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run all room-worker unit tests for regressions**
+
+```
+make test SERVICE=room-worker
+```
+
+Expected: PASS for all existing `TestHandler_ProcessAddMembers_*` cases (they used `ListNewMembers`; now they need to use `ListAddMemberCandidates`). Update each existing test's `store.EXPECT().ListNewMembers(...)` call to `store.EXPECT().ListAddMemberCandidates(...)` with equivalent semantics:
+- Where the test expected `ListNewMembers` to return `["alice"]` (truly new), now expect `ListAddMemberCandidates` to return `[{Account: "alice", HasSubscription: false, HasIndividualRoomMember: false}]`.
+- Where the test expected `ListNewMembers` to return `[]` (everyone already subscribed), now expect `ListAddMemberCandidates` to return per-existing-user candidate rows with `HasSubscription: true`.
+
+Re-run after fixups.
+
+- [ ] **Step 6: Remove `ListNewMembers` from the store interface and implementation**
+
+Edit `room-worker/store.go` to remove the `ListNewMembers` method declaration (lines 68-74) and the comment block above it.
+
+Edit `room-worker/store_mongo.go` to remove the `ListNewMembers` function body (lines 387+).
+
+Regenerate mocks:
+
+```
+make generate SERVICE=room-worker
+```
+
+Build and run tests:
+
+```
+make test SERVICE=room-worker
+make test-integration SERVICE=room-worker
+```
+
+Both pass.
+
+- [ ] **Step 7: Add an integration test for the bug scenario**
+
+Add to `room-worker/integration_test.go`:
+
+```go
+func TestHandler_ProcessAddMembers_OrgToIndividualUpgrade_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ cap := newPublishCapture(t)
+ h := NewHandler(store, "site-a", cap.fn(), testKeyStore, testKeySender)
+
+ const roomID = "room-1"
+ mustInsertRoom(t, db, &model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "Room 1"})
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱丽丝", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_owner", Account: "owner", EngName: "Owner", ChineseName: "拥有者", SiteID: "site-a"})
+ // Pre-state: alice is in the room via org-eng. Subscription exists, org room_members row exists, no individual row.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "org-eng", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, Users: []string{"alice"}, RequesterAccount: "owner", RequesterID: "u_owner",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ requestID := idgen.GenerateRequestID()
+ require.NoError(t, h.processAddMembers(natsutil.WithRequestID(ctx, requestID), data))
+
+ subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID, "u.account": "alice"})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), subCount, "no duplicate subscription created")
+
+ indivCount, err := db.Collection("room_members").CountDocuments(ctx, bson.M{
+ "rid": roomID, "member.type": "individual", "member.account": "alice",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), indivCount, "individual room_members row written on the upgrade path")
+}
+```
+
+`newPublishCapture`, `testKeyStore`, `testKeySender` are existing helpers used elsewhere in the file (see lines around 626, 700 for the pattern).
+
+- [ ] **Step 8: Run integration tests**
+
+```
+make test-integration SERVICE=room-worker
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```
+git add room-worker/store.go room-worker/store_mongo.go room-worker/handler.go room-worker/handler_test.go room-worker/integration_test.go room-worker/mock_store_test.go
+git commit -m "fix(room-worker): create individual room_members row on org→individual member.add"
+```
+
+---
+
+## Task 4: Swap `CountNewMembers` in `room-service/store_mongo.go` to the lite pipeline
+
+**Files:**
+- Modify: `room-service/store_mongo.go`, `room-service/integration_test.go`
+
+**Approach:** Replace `GetNewMembersPipeline` with `GetCapacityCheckPipeline` for the `roomID != ""` path. Add a regression test that confirms re-adding an org-only user as an individual returns 0 (capacity unchanged).
+
+- [ ] **Step 1: Add the failing capacity-regression test**
+
+Add to `room-service/integration_test.go`:
+
+```go
+func TestMongoStore_CountNewMembers_OrgOnlyUserCountsZero_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", SectID: "org-eng", SiteID: "site-a"})
+ // Alice already has a subscription via org-eng — adding her individually should add 0 new subs.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel,
+ })
+
+ n, err := store.CountNewMembers(ctx, nil, []string{"alice"}, roomID, "")
+ require.NoError(t, err)
+ assert.Equal(t, 0, n, "alice already has a sub via org — capacity unchanged")
+}
+```
+
+- [ ] **Step 2: Run, verify it currently passes (the existing pipeline already filters by subscription)**
+
+```
+make test-integration SERVICE=room-service -run TestMongoStore_CountNewMembers_OrgOnlyUserCountsZero_Integration
+```
+
+Expected: PASS (today the existing `GetNewMembersPipeline` happens to filter subscribed users; the test guards against regression while we swap the pipeline).
+
+- [ ] **Step 3: Swap the pipeline in `room-service/store_mongo.go` `CountNewMembers`**
+
+Locate `CountNewMembers` (around line 278). For the `roomID != ""` path, switch from `pipelines.GetNewMembersPipeline(orgIDs, directAccounts, roomID, excludeAccount)` to `pipelines.GetCapacityCheckPipeline(orgIDs, directAccounts, roomID, excludeAccount)`. Keep `GetNewMembersPipeline` for the `roomID == ""` create-room case. Full diff:
+
+```go
+func (s *MongoStore) CountNewMembers(ctx context.Context, orgIDs, directAccounts []string, roomID, excludeAccount string) (int, error) {
+ if len(orgIDs) == 0 && len(directAccounts) == 0 {
+ return 0, nil
+ }
+ var pipeline bson.A
+ if roomID == "" {
+ pipeline = pipelines.GetNewMembersPipeline(orgIDs, directAccounts, "", excludeAccount)
+ } else {
+ pipeline = pipelines.GetCapacityCheckPipeline(orgIDs, directAccounts, roomID, excludeAccount)
+ }
+ pipeline = append(pipeline, bson.M{"$count": "n"})
+
+ cursor, err := s.users.Aggregate(ctx, pipeline)
+ if err != nil {
+ return 0, fmt.Errorf("count new members: %w", err)
+ }
+ var results []struct {
+ Count int `bson:"n"`
+ }
+ if err := cursor.All(ctx, &results); err != nil {
+ return 0, fmt.Errorf("decode count new members: %w", err)
+ }
+ if len(results) == 0 {
+ return 0, nil
+ }
+ return results[0].Count, nil
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+```
+make test-integration SERVICE=room-service
+make test SERVICE=room-service
+```
+
+Both pass.
+
+- [ ] **Step 5: Commit**
+
+```
+git add room-service/store_mongo.go room-service/integration_test.go
+git commit -m "perf(room-service): use GetCapacityCheckPipeline for CountNewMembers"
+```
+
+---
+
+# Part 2 — DeptId feature
+
+## Task 5: Add `SectTCName`, `DeptID`, `DeptName`, `DeptTCName` fields to `User`
+
+**Files:**
+- Modify: `pkg/model/user.go`, `pkg/model/model_test.go`
+
+- [ ] **Step 1: Extend the failing roundtrip test for `User` in `pkg/model/model_test.go`**
+
+Find the existing `User` roundtrip case (likely in a table-driven test like `TestRoundTripModels` that iterates over types). Add field values for the new fields:
+
+```go
+{
+ name: "User with sect+dept",
+ in: &User{
+ ID: "u1", Account: "alice", SiteID: "site-a",
+ SectID: "S", SectName: "Sect", SectTCName: "部",
+ DeptID: "D", DeptName: "Dept", DeptTCName: "處",
+ EngName: "Alice", ChineseName: "爱丽丝",
+ },
+},
+```
+
+If the file uses a generic `roundTrip` helper, no new test function is needed — augment the existing data table.
+
+- [ ] **Step 2: Run the test, verify it fails**
+
+```
+make test SERVICE=pkg/model
+```
+
+(If `make test SERVICE=` only accepts top-level service names, run `cd pkg/model && go test ./...` instead.)
+
+Expected: FAIL — `SectTCName`/`DeptID`/`DeptName`/`DeptTCName` are not fields on `User`.
+
+- [ ] **Step 3: Add the fields to `pkg/model/user.go`**
+
+```go
+type User struct {
+ ID string `json:"id" bson:"_id"`
+ Account string `json:"account" bson:"account"`
+ SiteID string `json:"siteId" bson:"siteId"`
+ SectID string `json:"sectId" bson:"sectId"`
+ SectName string `json:"sectName" bson:"sectName"`
+ SectTCName string `json:"sectTCName" bson:"sectTCName"`
+ DeptID string `json:"deptId" bson:"deptId"`
+ DeptName string `json:"deptName" bson:"deptName"`
+ DeptTCName string `json:"deptTCName" bson:"deptTCName"`
+ EngName string `json:"engName" bson:"engName"`
+ ChineseName string `json:"chineseName" bson:"chineseName"`
+ EmployeeID string `json:"employeeId" bson:"employeeId"`
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+```
+make test SERVICE=pkg/model
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```
+git add pkg/model/user.go pkg/model/model_test.go
+git commit -m "feat(model): add SectTCName, DeptID, DeptName, DeptTCName fields to User"
+```
+
+---
+
+## Task 6: Add `(deptId, account)` index in `room-service/store_mongo.go EnsureIndexes`
+
+**Files:**
+- Modify: `room-service/store_mongo.go`
+
+**Approach:** Mirror the existing `(sectId, account)` index. Idempotent index creation; no test needed beyond confirming `EnsureIndexes` still returns nil for an existing setup.
+
+- [ ] **Step 1: Add the index creation**
+
+In `room-service/store_mongo.go` `EnsureIndexes`, after the existing `(sectId, account)` index block (around line 68-72), append:
+
+```go
+ if _, err := s.users.Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "deptId", Value: 1}, {Key: "account", Value: 1}},
+ }); err != nil {
+ return fmt.Errorf("ensure users (deptId,account) index: %w", err)
+ }
+```
+
+- [ ] **Step 2: Run the existing integration tests; `EnsureIndexes` is called by the test setup**
+
+```
+make test-integration SERVICE=room-service
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```
+git add room-service/store_mongo.go
+git commit -m "feat(room-service): add (deptId, account) index for deptId-matching pipelines"
+```
+
+---
+
+## Task 7: Extract `combineWithFallback`, refactor `displayName`, add `displayOrg`, update `formatRemovedOrg`
+
+**Files:**
+- Modify: `room-worker/sysmsg.go`
+- Create: `room-worker/sysmsg_test.go` (if not present)
+
+**Approach:** Refactor `displayName(user)` to call a shared helper, add the parallel `displayOrg(name, tcName, orgID)`, change `formatRemovedOrg` signature from `(sectName)` to `(name, tcName, orgID)`. The change is callable but unused until Task 10.
+
+- [ ] **Step 1: Write failing unit tests in `room-worker/sysmsg_test.go`**
+
+Create the file if it doesn't exist, mirroring the package-internal test style:
+
+```go
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/hmchangw/chat/pkg/model"
+)
+
+func TestCombineWithFallback(t *testing.T) {
+ tests := []struct {
+ name string
+ first, second, fb string
+ want string
+ }{
+ {"both", "Eng", "中", "x", "Eng 中"},
+ {"only first", "Eng", "", "x", "Eng"},
+ {"only second", "", "中", "x", "中"},
+ {"both empty", "", "", "fallback", "fallback"},
+ {"equal halves", "Same", "Same", "x", "Same"},
+ {"first whitespace", " ", "中", "x", "中"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, combineWithFallback(tc.first, tc.second, tc.fb))
+ })
+ }
+}
+
+func TestDisplayName_DelegatesToCombineWithFallback(t *testing.T) {
+ u := &model.User{Account: "alice", EngName: "Alice", ChineseName: "爱丽丝"}
+ assert.Equal(t, "Alice 爱丽丝", displayName(u))
+
+ u2 := &model.User{Account: "bob"}
+ assert.Equal(t, "bob", displayName(u2), "both names empty → falls back to Account")
+}
+
+func TestDisplayOrg(t *testing.T) {
+ assert.Equal(t, "Eng 工程部", displayOrg("Eng", "工程部", "orgX"))
+ assert.Equal(t, "Eng", displayOrg("Eng", "", "orgX"))
+ assert.Equal(t, "工程部", displayOrg("", "工程部", "orgX"))
+ assert.Equal(t, "orgX", displayOrg("", "", "orgX"))
+}
+
+func TestFormatRemovedOrg(t *testing.T) {
+ assert.Equal(t, `"Eng 工程部" has been removed from the channel`, formatRemovedOrg("Eng", "工程部", "orgX"))
+ assert.Equal(t, `"orgX" has been removed from the channel`, formatRemovedOrg("", "", "orgX"))
+}
+```
+
+- [ ] **Step 2: Run, verify it fails**
+
+```
+make test SERVICE=room-worker -run "TestCombineWithFallback|TestDisplayOrg|TestFormatRemovedOrg"
+```
+
+Expected: FAIL — `combineWithFallback` and `displayOrg` undefined; `formatRemovedOrg` has the wrong signature.
+
+- [ ] **Step 3: Create the shared helper in `pkg/displayfmt/combine.go`**
+
+Both room-worker (sys-messages) and room-service (member-list enrichment) need the same combine logic. Put it in a shared package so there's exactly one definition:
+
+```go
+// pkg/displayfmt/combine.go
+package displayfmt
+
+import "strings"
+
+// CombineWithFallback returns first+" "+second when both present, the non-empty side, or fallback when both empty.
+func CombineWithFallback(first, second, fallback string) string {
+ first = strings.TrimSpace(first)
+ second = strings.TrimSpace(second)
+ switch {
+ case first == "" && second == "":
+ return fallback
+ case first == "":
+ return second
+ case second == "":
+ return first
+ case first == second:
+ return first
+ default:
+ return first + " " + second
+ }
+}
+```
+
+Also add `pkg/displayfmt/combine_test.go` with the same table-driven cases as Step 1 above (rewrite as `TestCombineWithFallback` against the exported `displayfmt.CombineWithFallback`). The Step-1 worker-only test is then trimmed to cover only `displayName`/`displayOrg`/`formatRemovedOrg`.
+
+- [ ] **Step 4: Update `room-worker/sysmsg.go` to delegate**
+
+Replace the file's current contents below the package declaration with:
+
+```go
+package main
+
+import (
+ "github.com/hmchangw/chat/pkg/displayfmt"
+ "github.com/hmchangw/chat/pkg/model"
+)
+
+func displayName(u *model.User) string {
+ return displayfmt.CombineWithFallback(u.EngName, u.ChineseName, u.Account)
+}
+
+func displayOrg(name, tcName, orgID string) string {
+ return displayfmt.CombineWithFallback(name, tcName, orgID)
+}
+
+func quoted(name string) string {
+ return "\"" + name + "\""
+}
+
+func formatAddedSingle(requester, added *model.User) string {
+ return quoted(displayName(requester)) + " added " + quoted(displayName(added)) + " to the channel"
+}
+
+func formatAddedMulti(requester *model.User) string {
+ return quoted(displayName(requester)) + " added members to the channel"
+}
+
+func formatRemovedUser(user *model.User) string {
+ return quoted(displayName(user)) + " has been removed from the channel"
+}
+
+func formatRemovedOrg(name, tcName, orgID string) string {
+ return quoted(displayOrg(name, tcName, orgID)) + " has been removed from the channel"
+}
+
+func formatLeft(user *model.User) string {
+ return quoted(displayName(user)) + " left the channel"
+}
+```
+
+- [ ] **Step 5: Update the one current caller of `formatRemovedOrg` in `room-worker/handler.go` to compile**
+
+In `processRemoveOrg` (around line 631), update the temporary call site so the build is green; Task 10 will rewrite the surrounding logic:
+
+```go
+Content: formatRemovedOrg(sectName, "", req.OrgID),
+```
+
+This is a transitional stub — Task 10 replaces it with the proper dept-first resolution.
+
+- [ ] **Step 6: Run the unit tests**
+
+```
+make test SERVICE=pkg/displayfmt
+make test SERVICE=room-worker
+```
+
+Both pass.
+
+- [ ] **Step 7: Commit**
+
+```
+git add pkg/displayfmt/ room-worker/sysmsg.go room-worker/sysmsg_test.go room-worker/handler.go
+git commit -m "refactor: extract pkg/displayfmt.CombineWithFallback shared by sys-msg and enrichment"
+```
+
+---
+
+## Task 8: Extend pipelines to match `deptId` in `pkg/pipelines/member.go`
+
+**Files:**
+- Modify: `pkg/pipelines/member.go`
+- Test: `room-worker/integration_test.go`
+
+**Approach:** Add the second `$or` clause to `matchCandidates` (used by `GetCapacityCheckPipeline` and `GetAddMemberCandidatesPipeline`) and to the existing `GetNewMembersPipeline`. Verify both paths via integration tests.
+
+- [ ] **Step 1: Add the failing integration test for dept-matching**
+
+Add to `room-worker/integration_test.go`:
+
+```go
+func TestMongoStore_ListAddMemberCandidates_DeptMatching_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", DeptID: "dept-X", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", SectID: "dept-X", SiteID: "site-a"})
+
+ got, err := store.ListAddMemberCandidates(ctx, []string{"dept-X"}, nil, "room-1")
+ require.NoError(t, err)
+
+ accounts := map[string]bool{}
+ for _, c := range got {
+ accounts[c.Account] = true
+ }
+ assert.True(t, accounts["alice"], "alice matches by deptId")
+ assert.True(t, accounts["bob"], "bob matches by sectId (the orgID coincides)")
+ assert.Len(t, got, 2)
+}
+```
+
+- [ ] **Step 2: Run, verify it fails (alice missing)**
+
+```
+make test-integration SERVICE=room-worker -run TestMongoStore_ListAddMemberCandidates_DeptMatching_Integration
+```
+
+Expected: FAIL — alice is matched only by deptId which the pipeline doesn't yet consider.
+
+- [ ] **Step 3: Extend `matchCandidates` and `GetNewMembersPipeline` in `pkg/pipelines/member.go`**
+
+In `matchCandidates`, update the org-branch appending:
+
+```go
+ if len(orgIDs) > 0 {
+ orFilter = append(orFilter, bson.M{"sectId": bson.M{"$in": orgIDs}})
+ orFilter = append(orFilter, bson.M{"deptId": bson.M{"$in": orgIDs}})
+ }
+```
+
+In `GetNewMembersPipeline` (the existing function), make the same change at the analogous location — the existing `orFilter` construction at lines 29-35 of the current file.
+
+- [ ] **Step 4: Run integration tests**
+
+```
+make test-integration SERVICE=room-worker
+make test-integration SERVICE=room-service
+```
+
+Both pass. The new test now succeeds.
+
+- [ ] **Step 5: Commit**
+
+```
+git add pkg/pipelines/member.go room-worker/integration_test.go
+git commit -m "feat(pipelines): match deptId alongside sectId in candidate $or"
+```
+
+---
+
+## Task 9: Update `OrgMemberStatus` + `GetOrgMembersWithIndividualStatus` for dept fields and `member.id` lookup
+
+**Files:**
+- Modify: `room-worker/store.go`, `room-worker/store_mongo.go`, `room-worker/integration_test.go`
+- Regenerate: `room-worker/mock_store_test.go`
+
+**Approach:** Slim `OrgMemberStatus` to `(Name, TCName, IsDept, …)` — the pipeline resolves per-row sect-vs-dept selection. Switch the `room_members` lookup from `member.account` to `member.id` (covered by the existing unique index). Integration test verifies all four shapes.
+
+- [ ] **Step 1: Add the failing integration test**
+
+Add to `room-worker/integration_test.go`:
+
+```go
+func TestMongoStore_GetOrgMembersWithIndividualStatus_DeptAndSect_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ mustInsertUser(t, db, &model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering", DeptTCName: "工程部",
+ })
+ mustInsertUser(t, db, &model.User{
+ ID: "u_bob", Account: "bob", SiteID: "site-a",
+ SectID: "X", SectName: "Eng Sect", SectTCName: "工程組",
+ })
+
+ const roomID = "room-1"
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel,
+ })
+ // Bob has an individual room_members row (member.id = user._id).
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "u_bob", Type: model.RoomMemberIndividual, Account: "bob"},
+ })
+ require.NoError(t, err)
+
+ got, err := store.GetOrgMembersWithIndividualStatus(ctx, roomID, "X")
+ require.NoError(t, err)
+
+ byAccount := map[string]OrgMemberStatus{}
+ for _, m := range got {
+ byAccount[m.Account] = m
+ }
+ require.Len(t, byAccount, 2)
+ assert.Equal(t, OrgMemberStatus{
+ Account: "alice", SiteID: "site-a",
+ Name: "Engineering", TCName: "工程部", IsDept: true, HasIndividualMembership: false,
+ }, byAccount["alice"])
+ assert.Equal(t, OrgMemberStatus{
+ Account: "bob", SiteID: "site-a",
+ Name: "Eng Sect", TCName: "工程組", IsDept: false, HasIndividualMembership: true,
+ }, byAccount["bob"])
+}
+```
+
+- [ ] **Step 2: Run, verify it fails**
+
+```
+make test-integration SERVICE=room-worker -run TestMongoStore_GetOrgMembersWithIndividualStatus_DeptAndSect_Integration
+```
+
+Expected: FAIL — `OrgMemberStatus` doesn't have `Name`/`TCName`/`IsDept`.
+
+- [ ] **Step 3: Update `OrgMemberStatus` in `room-worker/store.go`**
+
+Replace the existing struct (around line 28-33):
+
+```go
+type OrgMemberStatus struct {
+ Account string `bson:"account"`
+ SiteID string `bson:"siteId"`
+ Name string `bson:"name"`
+ TCName string `bson:"tcName"`
+ IsDept bool `bson:"isDept"`
+ HasIndividualMembership bool `bson:"hasIndividualMembership"`
+}
+```
+
+- [ ] **Step 4: Regenerate mocks**
+
+```
+make generate SERVICE=room-worker
+```
+
+- [ ] **Step 5: Rewrite `GetOrgMembersWithIndividualStatus` in `room-worker/store_mongo.go`**
+
+Replace the function body (around line 261-294):
+
+```go
+func (s *MongoStore) GetOrgMembersWithIndividualStatus(ctx context.Context, roomID, orgID string) ([]OrgMemberStatus, error) {
+ pipeline := mongo.Pipeline{
+ {{Key: "$match", Value: bson.M{"$or": bson.A{
+ bson.M{"sectId": orgID},
+ bson.M{"deptId": orgID},
+ }}}},
+ {{Key: "$addFields", Value: bson.M{
+ "isDept": bson.M{"$eq": bson.A{"$deptId", orgID}},
+ "name": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", orgID}}, "$deptName", "$sectName"}},
+ "tcName": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", orgID}}, "$deptTCName", "$sectTCName"}},
+ }}},
+ {{Key: "$lookup", Value: bson.M{
+ "from": "room_members",
+ "let": bson.M{"uid": "$_id"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$rid", roomID}},
+ bson.M{"$eq": bson.A{"$member.type", "individual"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$uid"}},
+ }}}},
+ bson.M{"$limit": 1},
+ },
+ "as": "individualMembership",
+ }}},
+ {{Key: "$project", Value: bson.M{
+ "_id": 0,
+ "account": 1,
+ "siteId": 1,
+ "name": 1,
+ "tcName": 1,
+ "isDept": 1,
+ "hasIndividualMembership": bson.M{"$gt": bson.A{bson.M{"$size": "$individualMembership"}, 0}},
+ }}},
+ }
+ cursor, err := s.users.Aggregate(ctx, pipeline)
+ if err != nil {
+ return nil, fmt.Errorf("aggregate org members: %w", err)
+ }
+ defer cursor.Close(ctx)
+ var results []OrgMemberStatus
+ if err := cursor.All(ctx, &results); err != nil {
+ return nil, fmt.Errorf("decode org members: %w", err)
+ }
+ return results, nil
+}
+```
+
+- [ ] **Step 6: Run integration tests**
+
+```
+make test-integration SERVICE=room-worker -run TestMongoStore_GetOrgMembersWithIndividualStatus_DeptAndSect_Integration
+make test-integration SERVICE=room-worker
+```
+
+Both pass.
+
+- [ ] **Step 7: Run unit tests; fix compile errors in `room-worker/handler.go` `processRemoveOrg`**
+
+```
+make test SERVICE=room-worker
+```
+
+Expected: compile errors — `processRemoveOrg` reads `m.SectName` from `OrgMemberStatus`. As a compile-only stub (Task 10 rewrites this block properly), change the iteration:
+
+```go
+ sectName := ""
+ for _, m := range members {
+ if m.Name != "" {
+ sectName = m.Name
+ break
+ }
+ }
+```
+
+This keeps the existing behavior temporarily (pre-Task-10) — sect-or-dept name, no tiebreak, no orgID fallback yet.
+
+Run again, all green:
+
+```
+make test SERVICE=room-worker
+```
+
+- [ ] **Step 8: Commit**
+
+```
+git add room-worker/store.go room-worker/store_mongo.go room-worker/handler.go room-worker/integration_test.go room-worker/mock_store_test.go
+git commit -m "refactor(room-worker): OrgMemberStatus carries (Name,TCName,IsDept); lookup via member.id"
+```
+
+---
+
+## Task 10: Rewrite `processRemoveOrg` with dept-first tiebreak + `displayOrg` formatting; drop "missing SectName" permanent error
+
+**Files:**
+- Modify: `room-worker/handler.go`, `room-worker/handler_test.go`, `room-worker/integration_test.go`
+
+- [ ] **Step 1: Write failing unit tests covering the four cases**
+
+Add to `room-worker/handler_test.go`, modeled on `TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered` (line ~3928):
+
+```go
+func TestHandler_ProcessRemoveOrg_DeptFirstTiebreak(t *testing.T) {
+ cases := []struct {
+ name string
+ members []OrgMemberStatus
+ wantSect string // value placed in MemberRemoved.SectName
+ wantContent string // expected Message.Content
+ }{
+ {
+ name: "all sect users",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ {Account: "u2", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ },
+ wantSect: "Sect 組", wantContent: `"Sect 組" has been removed from the channel`,
+ },
+ {
+ name: "all dept users",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Dept", TCName: "部", IsDept: true, HasIndividualMembership: true},
+ },
+ wantSect: "Dept 部", wantContent: `"Dept 部" has been removed from the channel`,
+ },
+ {
+ name: "mixed — dept wins",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ {Account: "u2", SiteID: "site-a", Name: "Dept", TCName: "部", IsDept: true, HasIndividualMembership: true},
+ },
+ wantSect: "Dept 部", wantContent: `"Dept 部" has been removed from the channel`,
+ },
+ {
+ name: "all names empty — fall back to orgID",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "", TCName: "", IsDept: false, HasIndividualMembership: true},
+ },
+ wantSect: "o1", wantContent: `"o1" has been removed from the channel`,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "r1"
+ store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1").Return(tc.members, nil)
+ // toRemove empty (all have individual membership) → no DeleteSubscriptionsByAccounts expected.
+ store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil)
+ store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").
+ Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
+
+ var published []publishedMsg
+ h := &Handler{
+ store: store, siteID: "site-a",
+ publish: func(_ context.Context, subj string, data []byte, _ string) error {
+ published = append(published, publishedMsg{subj: subj, data: data})
+ return nil
+ },
+ keyStore: testKeyStore, keySender: testKeySender,
+ }
+
+ req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1}
+ require.NoError(t, h.processRemoveOrg(context.Background(), &req, nil, false))
+
+ sysMsg := findSysMsg(t, published, "site-a", "member_removed")
+ assert.Equal(t, tc.wantContent, sysMsg.Content)
+ var payload model.MemberRemoved
+ require.NoError(t, json.Unmarshal(sysMsg.SysMsgData, &payload))
+ assert.Equal(t, tc.wantSect, payload.SectName)
+ })
+ }
+}
+```
+
+Note: `publishedMsg`, `findSysMsg`, `testKeyStore`, `testKeySender` are existing test helpers used by `TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered`.
+
+- [ ] **Step 2: Run, verify it fails**
+
+```
+make test SERVICE=room-worker -run TestHandler_ProcessRemoveOrg_DeptFirstTiebreak
+```
+
+Expected: FAIL on the dept-wins and orgID-fallback cases.
+
+- [ ] **Step 3: Rewrite the name-harvest block in `room-worker/handler.go` `processRemoveOrg`**
+
+Locate the block at lines 508-523 (sectName harvest + permanent-error guard). Replace with the two-pass resolution:
+
+```go
+ // Two-pass resolution: dept-matching rows win on overlap; otherwise first row.
+ var name, tcName string
+ for _, m := range members {
+ if m.IsDept {
+ name, tcName = m.Name, m.TCName
+ break
+ }
+ }
+ if name == "" && tcName == "" {
+ for _, m := range members {
+ if !m.IsDept {
+ name, tcName = m.Name, m.TCName
+ break
+ }
+ }
+ }
+ if name == "" && tcName == "" {
+ slog.Warn("org-remove: no name resolved from any member; falling back to orgID", "roomID", req.RoomID, "orgID", req.OrgID)
+ }
+```
+
+Then update the sys-msg payload + Content (lines 618-631) to use `displayOrg` and the new formatter signature:
+
+```go
+ sysMsgPayload, _ := json.Marshal(model.MemberRemoved{
+ OrgID: req.OrgID,
+ SectName: displayOrg(name, tcName, req.OrgID),
+ RemovedUsersCount: len(toRemove),
+ })
+ // … (seed + sysMsg struct unchanged) …
+ sysMsg := model.Message{
+ ID: idgen.MessageIDFromRequestID(seed, "rmorg"),
+ RoomID: req.RoomID,
+ UserID: requester.ID,
+ UserAccount: requester.Account,
+ Type: model.MessageTypeMemberRemoved,
+ Content: formatRemovedOrg(name, tcName, req.OrgID),
+ SysMsgData: sysMsgPayload,
+ CreatedAt: now,
+ }
+```
+
+Delete the transitional stub from Task 7/9 (the `sectName := ""` loop and the `formatRemovedOrg(sectName, "", req.OrgID)` call — both replaced above).
+
+- [ ] **Step 4: Run, verify unit tests pass**
+
+```
+make test SERVICE=room-worker
+```
+
+All cases pass.
+
+- [ ] **Step 5: Commit**
+
+```
+git add room-worker/handler.go room-worker/handler_test.go room-worker/integration_test.go
+git commit -m "feat(room-worker): processRemoveOrg uses dept-first tiebreak with displayOrg + orgID fallback"
+```
+
+---
+
+## Task 11: Update enrichment `_orgMatch` lookup in `room-service/store_mongo.go` for prefer-dept + orgId fallback
+
+**Files:**
+- Modify: `room-service/store_mongo.go`, `room-service/integration_test.go`
+
+**Approach:** Replace the existing single-field `_orgMatch` lookup (sectId-only) with the spec's full pipeline that unions sectId+deptId, sorts by `_isDept desc`, picks the first `(name, tcName)`, and builds the combined `display` string. The outer `$set` falls back to `member.id` when display is empty.
+
+- [ ] **Step 1: Write the failing integration test**
+
+Add to `room-service/integration_test.go`:
+
+```go
+func TestMongoStore_ListRoomMembers_OrgDisplay_DeptFirst_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ mustInsertRoom(t, db, &model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "R"})
+ mustInsertUser(t, db, &model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering", DeptTCName: "工程部",
+ })
+ mustInsertUser(t, db, &model.User{
+ ID: "u_bob", Account: "bob", SiteID: "site-a",
+ SectID: "X", SectName: "Sect", SectTCName: "組",
+ })
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "X", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListRoomMembers(ctx, roomID, true)
+ require.NoError(t, err)
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "Engineering 工程部", got[0].Member.SectName, "dept wins on overlap; name+tcName combined")
+}
+
+func TestMongoStore_ListRoomMembers_OrgDisplay_FallbackToOrgId_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ mustInsertRoom(t, db, &model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "R"})
+ // Org Y has no users at all — display must fall back to the raw orgID.
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "Y", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListRoomMembers(ctx, roomID, true)
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "Y", got[0].Member.SectName, "no matching users → display falls back to member.id")
+}
+```
+
+- [ ] **Step 2: Run, verify they fail**
+
+```
+make test-integration SERVICE=room-service -run "TestMongoStore_ListRoomMembers_OrgDisplay_DeptFirst_Integration|TestMongoStore_ListRoomMembers_OrgDisplay_FallbackToOrgId_Integration"
+```
+
+Expected: FAIL — dept branch not yet considered; fallback not in place.
+
+- [ ] **Step 3: Replace the `_orgMatch` lookup + `display.sectName` `$set` in `room-service/store_mongo.go`**
+
+Locate the enrichment block (lines 451-489). Replace the `_orgMatch` `$lookup` pipeline with:
+
+```go
+ {{Key: "$lookup", Value: bson.M{
+ "from": "users",
+ "let": bson.M{
+ "orgId": "$member.id",
+ "mtyp": "$member.type",
+ },
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$$mtyp", "org"}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ bson.M{"$eq": bson.A{"$sectId", "$$orgId"}},
+ }},
+ }}}},
+ bson.M{"$addFields": bson.M{
+ "_isDept": bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ "_name": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptName", "$sectName"}},
+ "_tcName": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptTCName", "$sectTCName"}},
+ }},
+ bson.M{"$group": bson.M{
+ "_id": nil,
+ "isDept": bson.M{"$max": "$_isDept"},
+ "deptName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_name", nil}}},
+ "deptTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_tcName", nil}}},
+ "sectName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_name"}}},
+ "sectTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_tcName"}}},
+ "memberCount": bson.M{"$sum": 1},
+ }},
+ },
+ "as": "_orgMatch",
+ }}},
+```
+
+Then update the `$set display` block (existing lines 475-489) so it exposes the raw pair plus memberCount for Go-side resolution:
+
+```go
+ {{Key: "$set", Value: bson.M{
+ "display": bson.M{
+ "engName": bson.M{"$arrayElemAt": bson.A{"$_userMatch.engName", 0}},
+ "chineseName": bson.M{"$arrayElemAt": bson.A{"$_userMatch.chineseName", 0}},
+ "isOwner": bson.M{"$in": bson.A{
+ "owner",
+ bson.M{"$ifNull": bson.A{
+ bson.M{"$arrayElemAt": bson.A{"$_subMatch.roles", 0}},
+ bson.A{},
+ }},
+ }},
+ "orgRaw": bson.M{"$arrayElemAt": bson.A{"$_orgMatch", 0}},
+ "memberCount": bson.M{"$arrayElemAt": bson.A{"$_orgMatch.memberCount", 0}},
+ },
+ }}},
+```
+
+Then in Go (`room-service/store_mongo.go ListRoomMembers` decode loop, after `cursor.All`), resolve the final `display.sectName` per row by reusing the worker's `combineWithFallback` helper. Move that helper to a shared location both services can import (e.g. `pkg/displayfmt/combine.go`) — simpler than duplicating it:
+
+```go
+// pkg/displayfmt/combine.go
+package displayfmt
+
+import "strings"
+
+// CombineWithFallback joins first and second with a space, falling back to the non-empty side or the fallback.
+func CombineWithFallback(first, second, fallback string) string {
+ first = strings.TrimSpace(first)
+ second = strings.TrimSpace(second)
+ switch {
+ case first == "" && second == "":
+ return fallback
+ case first == "":
+ return second
+ case second == "":
+ return first
+ case first == second:
+ return first
+ default:
+ return first + " " + second
+ }
+}
+```
+
+Update `room-worker/sysmsg.go` to import and delegate to `pkg/displayfmt.CombineWithFallback` (keep the local `displayName`/`displayOrg` wrappers for the worker's call sites).
+
+In `room-service ListRoomMembers`, after decoding:
+
+```go
+for i := range members {
+ if members[i].Member.Type != model.RoomMemberOrg {
+ continue
+ }
+ raw := members[i].Display.OrgRaw
+ name, tcName := raw.SectName, raw.SectTCName
+ if raw.IsDept && raw.DeptName != "" {
+ name, tcName = raw.DeptName, raw.DeptTCName
+ }
+ members[i].Display.SectName = displayfmt.CombineWithFallback(name, tcName, members[i].Member.ID)
+ members[i].Display.OrgRaw = nil // strip from wire output
+}
+```
+
+This keeps the pipeline at 3 inner stages (vs. the spec's earlier 6-stage variant), eliminates BSON-Go logic duplication, and gives both services a single shared helper for the combine. The `display.sectName` wire shape is unchanged.
+
+- [ ] **Step 4: Run integration tests**
+
+```
+make test-integration SERVICE=room-service
+```
+
+Both new tests pass; existing enrichment tests continue to pass.
+
+- [ ] **Step 5: Commit**
+
+```
+git add room-service/store_mongo.go room-service/integration_test.go
+git commit -m "feat(room-service): enrichment prefers dept on overlap, falls back to orgID"
+```
+
+---
+
+# Part 3 — Frontend dedup short-circuit
+
+## Task 12: `CreateRoomDialog` navigates directly on `dm already exists` reply
+
+**Files:**
+- Modify: `chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx`, `chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx`
+
+- [ ] **Step 1: Rewrite the failing test in `CreateRoomDialog.test.jsx`**
+
+Find the existing test at `:139` (`'treats a "dm already exists" reply as success and navigates to the existing room'`). Rewrite it so `summaries` is empty (no synthetic match) and the test asserts `onCreated` is called even though `summaries` never gets mutated:
+
+```jsx
+it('navigates directly to the existing room on dm-already-exists reply (no summaries-wait)', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ try {
+ const onCreated = vi.fn()
+ const onClose = vi.fn()
+ const { mocks } = setup({
+ summaries: [], // critical: no pre-populated row; pre-Part-3 code would deadlock on the 3s timeout.
+ createRoomResolved: { sync: { error: 'dm already exists', roomId: 'r-existing' } },
+ onCreated,
+ onClose,
+ })
+
+ fireEvent.click(screen.getByLabelText(/Pick people/i))
+ // ... rest of the click-through to fire createRoom; mirror the existing test's setup ...
+ fireEvent.click(screen.getByRole('button', { name: /Create/i }))
+
+ await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1))
+ expect(onCreated).toHaveBeenCalledWith(expect.objectContaining({ id: 'r-existing', type: 'dm' }))
+ expect(onClose).toHaveBeenCalledTimes(1)
+
+ // Advance past the 3-second timeout; banner must NOT appear.
+ await vi.advanceTimersByTimeAsync(3500)
+ expect(screen.queryByText(/taking longer than expected/i)).toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+})
+```
+
+(Adapt the surrounding fixture setup to match the file's existing `setup()` signature.)
+
+- [ ] **Step 2: Run the test, verify it fails**
+
+```
+cd chat-frontend && npm test -- CreateRoomDialog.test
+```
+
+Expected: FAIL — current code enters `setPendingRoom`, never matches summaries (empty), hits the 3-second timeout, and `onCreated` is never called.
+
+- [ ] **Step 3: Modify `CreateRoomDialog.jsx` `handleSubmit` to short-circuit on dedup**
+
+Find the block at lines 87-105 in `CreateRoomDialog.jsx`. Replace:
+
+```jsx
+ const { sync } = await createRoom(
+ nats,
+ { name: trimmedName, users: finalUsers, orgs: finalOrgs, channels: finalChannels },
+ { treatAsSuccess: isDMExistsReply }
+ )
+ const roomId = sync.roomId
+ const roomType = sync.roomType || (isDMExistsReply(sync) ? 'dm' : undefined)
+ const displayName = trimmedName || finalUsers[0] || ''
+ setPendingRoom({ id: roomId, type: roomType, siteId: user.siteId, name: displayName })
+```
+
+With:
+
+```jsx
+ const { sync } = await createRoom(
+ nats,
+ { name: trimmedName, users: finalUsers, orgs: finalOrgs, channels: finalChannels },
+ { treatAsSuccess: isDMExistsReply }
+ )
+ const roomId = sync.roomId
+ const displayName = trimmedName || finalUsers[0] || ''
+
+ if (isDMExistsReply(sync)) {
+ // Dedup branch: server already confirmed the DM; skip the summaries-wait that can trip the 3s banner on a BUCKETS_LOADED race.
+ onCreated({ id: roomId, type: 'dm', siteId: user.siteId, name: displayName })
+ onClose()
+ return
+ }
+
+ setPendingRoom({ id: roomId, type: sync.roomType, siteId: user.siteId, name: displayName })
+```
+
+- [ ] **Step 4: Run the test, verify it passes**
+
+```
+cd chat-frontend && npm test -- CreateRoomDialog.test
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run frontend typecheck + full test suite**
+
+```
+cd chat-frontend && npm run typecheck && npm test
+```
+
+Both pass.
+
+- [ ] **Step 6: Commit**
+
+```
+git add chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.test.jsx
+git commit -m "fix(chat-frontend): dedup reply navigates directly without summaries-wait"
+```
+
+---
+
+# Final verification
+
+- [ ] **Step 1: Run the full test matrix**
+
+```
+make lint
+make test
+make test-integration
+make sast
+cd chat-frontend && npm run typecheck && npm test
+```
+
+All green.
+
+- [ ] **Step 2: Push the final branch**
+
+```
+git push origin claude/fix-member-subscription-bug-QZhjc
+```
+
+---
+
+# Part 4 — Remove `Room.CreatedBy` (and rework replay-equivalence)
+
+## Task 13: Drop `CreatedBy` from Room, rewrite duplicate-key check to use requester-sub-exists
+
+**Files:**
+- Modify: `pkg/model/room.go`, `pkg/model/model_test.go`
+- Modify: `room-worker/handler.go`, `room-worker/handler_test.go`, `room-worker/integration_test.go`
+- Modify: `chat-frontend/src/api/types.ts`, `chat-frontend/src/api/fetchSidebarBuckets/index.ts`
+- Modify: `docs/client-api.md`
+
+**Approach:** Add the DM-concurrent-create failing test first (proves the current `CreatedBy`-based equivalence check is wrong). Rewrite the duplicate-key blocks in `processCreateRoom` and the sync DM path to use `GetSubscription(requester.Account, room.ID)` instead. Drop the field from the model and all surfaces.
+
+- [ ] **Step 1: Write failing integration test for DM concurrent-create**
+
+Add to `room-worker/integration_test.go`:
+
+```go
+func TestHandler_ProcessCreateRoom_DMConcurrentByCounterpart_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ cap := newPublishCapture(t)
+ h := NewHandler(store, "site-a", cap.fn(), testKeyStore, testKeySender)
+
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", EngName: "Bob", ChineseName: "鲍", SiteID: "site-a"})
+
+ // Pre-state: alice's worker already raced to create the DM. Room exists + both subs.
+ roomID := idgen.BuildDMRoomID("u_alice", "u_bob")
+ mustInsertRoom(t, db, &model.Room{
+ ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a", Name: "",
+ UIDs: []string{"u_alice", "u_bob"}, Accounts: []string{"alice", "bob"},
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ Name: "bob", RoomType: model.RoomTypeDM,
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_bob", Account: "bob"},
+ Name: "alice", RoomType: model.RoomTypeDM,
+ })
+
+ // Bob's worker now processes Bob's canonical create event (Bob raced too).
+ req := model.CreateRoomRequest{
+ RoomID: roomID, Users: []string{"alice"},
+ RequesterID: "u_bob", RequesterAccount: "bob",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ requestID := idgen.GenerateRequestID()
+ err := h.processCreateRoom(natsutil.WithRequestID(ctx, requestID), data)
+ require.NoError(t, err, "bob's race must not fail with collision; alice's worker already wrote both subs")
+
+ // Exactly one room, exactly one sub per user — no duplicates.
+ roomCount, err := db.Collection("rooms").CountDocuments(ctx, bson.M{"_id": roomID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), roomCount)
+ subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), subCount)
+}
+```
+
+- [ ] **Step 2: Run, verify it fails with "room ID collision"**
+
+```
+make test-integration SERVICE=room-worker -run TestHandler_ProcessCreateRoom_DMConcurrentByCounterpart_Integration
+```
+
+Expected: FAIL — `processCreateRoom` returns `newPermanent("room ID collision ...")` because `existing.CreatedBy ("u_alice") != room.CreatedBy ("u_bob")`.
+
+- [ ] **Step 3: Strengthen the test — assert no extra writes**
+
+`BulkCreateSubscriptions` swallows duplicate-key errors silently. A buggy implementation that re-runs the full insert path could still produce `subCount == 2` without exercising the fix. Snapshot the pre-existing sub `_id`s and assert they're unchanged after the call:
+
+```go
+// Right before the processCreateRoom call:
+type subID struct{ ID string `bson:"_id"` }
+var preSubs []subID
+cursor, err := db.Collection("subscriptions").Find(ctx, bson.M{"roomId": roomID})
+require.NoError(t, err)
+require.NoError(t, cursor.All(ctx, &preSubs))
+require.Len(t, preSubs, 2)
+preIDs := map[string]bool{preSubs[0].ID: true, preSubs[1].ID: true}
+
+// After the call, in addition to the existing assertions:
+var postSubs []subID
+cursor, err = db.Collection("subscriptions").Find(ctx, bson.M{"roomId": roomID})
+require.NoError(t, err)
+require.NoError(t, cursor.All(ctx, &postSubs))
+require.Len(t, postSubs, 2, "no extra subscription docs created")
+for _, s := range postSubs {
+ assert.True(t, preIDs[s.ID], "subscription %s replaced — worker should reuse existing", s.ID)
+}
+```
+
+This makes the test fail under any implementation that double-inserts (even with silent dedup), proving the requester-sub-exists check is actually taking effect.
+
+- [ ] **Step 4: Add the private helper `reconcileRoomOnDuplicateKey` to `room-worker/handler.go`**
+
+Add near the top of the file (after the package-scoped error types, before any handler method). Single source of truth for both call sites:
+
+```go
+// reconcileRoomOnDuplicateKey is invoked on CreateRoom duplicate-key errors. It returns the existing room when the requester is a legitimate member (JetStream redelivery, or DM counterpart-raced-first), or a permanent error on a real ID collision.
+func (h *Handler) reconcileRoomOnDuplicateKey(ctx context.Context, want *model.Room, requesterAccount string) (*model.Room, error) {
+ existing, err := h.store.GetRoom(ctx, want.ID)
+ if err != nil {
+ return nil, fmt.Errorf("fetch on duplicate-key: %w", err)
+ }
+ if existing.Type != want.Type || existing.SiteID != want.SiteID {
+ return nil, newPermanent("room ID collision (existing type=%s site=%s; want %s/%s)",
+ existing.Type, existing.SiteID, want.Type, want.SiteID)
+ }
+ if _, err := h.store.GetSubscription(ctx, requesterAccount, want.ID); err != nil {
+ if errors.Is(err, model.ErrSubscriptionNotFound) {
+ return nil, newPermanent("room ID collision (requester %s not a member of existing room %s)",
+ requesterAccount, want.ID)
+ }
+ return nil, fmt.Errorf("check requester sub on duplicate-key: %w", err)
+ }
+ return existing, nil
+}
+```
+
+- [ ] **Step 5: Replace the duplicate-key block in `processCreateRoom` (`:1130-1153`) with a helper call**
+
+```go
+ if err := h.store.CreateRoom(ctx, room); err != nil {
+ if !mongo.IsDuplicateKeyError(err) {
+ return fmt.Errorf("create room: %w", err)
+ }
+ existing, err := h.reconcileRoomOnDuplicateKey(ctx, room, requester.Account)
+ if err != nil {
+ return err
+ }
+ room = existing
+ }
+```
+
+- [ ] **Step 6: Replace the duplicate-key block in the sync-DM path (`:1535-1558`) with the helper call**
+
+```go
+ if err := h.store.CreateRoom(ctx, room); err != nil {
+ if !mongo.IsDuplicateKeyError(err) {
+ return nil, fmt.Errorf("create room: %w", err)
+ }
+ existing, err := h.reconcileRoomOnDuplicateKey(ctx, room, requester.Account)
+ if err != nil {
+ if errors.Is(err, errPermanent) {
+ // Sync path needs the client-safe errRoomIDCollision sentinel instead.
+ slog.Error("sync DM: room ID collision", "roomID", room.ID, "requester", requester.Account)
+ return nil, errRoomIDCollision
+ }
+ return nil, err
+ }
+ room = existing
+ acceptedAt = existing.CreatedAt // sync path divergence — kept here at the caller
+ }
+```
+
+If the existing code maps permanent errors to a sentinel differently, mirror that mapping pattern instead.
+
+- [ ] **Step 7: Drop the `CreatedBy:` field set in both room literal constructions**
+
+In `room-worker/handler.go`, remove the `CreatedBy: requester.ID,` line from the `room := &model.Room{…}` constructions at `:1107` and `:1526`.
+
+- [ ] **Step 8: Drop `CreatedBy` from the model**
+
+In `pkg/model/room.go`, delete the `CreatedBy string `json:"createdBy" bson:"createdBy"`` line.
+
+- [ ] **Step 9: Update all test fixtures referencing `Room.CreatedBy`**
+
+The field is referenced in test fixtures across several files. Find every occurrence before editing — don't trust greps to be exhaustive after manual edits start:
+
+```
+grep -rn "CreatedBy" room-worker/ pkg/model/ | grep -v "mock_store_test\|.bak"
+```
+
+Expected files (verified at spec time):
+- `pkg/model/model_test.go` — Room roundtrip fixture(s). Remove every `CreatedBy:` field set.
+- `room-worker/integration_test.go` — at least one positive assertion (e.g. around `:607` `assert.Equal(t, "u_alice", room.CreatedBy)`). Remove the assertion line AND any `CreatedBy:` fixture sets.
+- `room-worker/handler_test.go` — any `CreatedBy:` in `&model.Room{…}` fixtures, and any test that asserted a collision based on `CreatedBy` mismatch (rewrite those to either expect success for the DM-concurrent case OR to trigger the new "requester not a member" path with a deliberate unrelated-room setup).
+
+After every file edit, re-run the grep to confirm no `CreatedBy` references remain anywhere in `room-worker/` or `pkg/model/`. The frontend has its own removal in Step 11.
+
+- [ ] **Step 10: Run the Go test matrix**
+
+```
+make test SERVICE=room-worker
+make test-integration SERVICE=room-worker
+make test SERVICE=room-service
+make test-integration SERVICE=room-service
+make test SERVICE=pkg/model
+```
+
+All green. The new DM-concurrent-create test passes; nothing else regresses.
+
+- [ ] **Step 11: Drop from frontend**
+
+`chat-frontend/src/api/types.ts:110` — remove `createdBy: string` from the `Room` interface.
+`chat-frontend/src/api/fetchSidebarBuckets/index.ts:135` — remove the `createdBy: '',` line.
+
+Frontend grep: `grep -rnE '\b(createdBy|createdByAccount)\b' chat-frontend/src/`. Neither field exists in the live request shape (the creator's account is taken from the NATS subject); both names should be stripped wherever they appear.
+
+Run:
+
+```
+cd chat-frontend && npm run typecheck && npm test
+```
+
+All green.
+
+- [ ] **Step 12: Update `docs/client-api.md`**
+
+Strip both names from the doc — neither corresponds to a real field on the wire:
+
+```
+grep -rnE '\b(createdBy|createdByAccount)\b' docs/client-api.md
+```
+
+Expected matches: the `| createdBy | ... |` / `| createdByAccount | ... |` rows in the Room and Create Room schema tables and the `"createdBy": "..."` / `"createdByAccount": "..."` lines in example JSON blocks. Remove every match; the creator's account is derived server-side from the `{account}` segment of the create-room subject.
+
+- [ ] **Step 13: Commit**
+
+```
+git add pkg/model/room.go pkg/model/model_test.go room-worker/handler.go room-worker/handler_test.go room-worker/integration_test.go chat-frontend/src/api/types.ts chat-frontend/src/api/fetchSidebarBuckets/index.ts docs/client-api.md
+git commit -m "refactor(room-worker): drop Room.CreatedBy, extract reconcileRoomOnDuplicateKey, use requester-sub-exists for replay equivalence"
+```
+
+---
+
+# Part 5 — Remove `target_user` Cassandra column
+
+## Task 14: Drop `TargetUser` from the Cassandra message model and schema
+
+**Files:**
+- Modify: `pkg/model/cassandra/message.go`
+- Modify: `docker-local/cassandra/init/10-table-messages_by_room.cql`, `11-table-thread_messages_by_room.cql`, `12-table-pinned_messages_by_room.cql`, `13-table-messages_by_id.cql`
+- Modify: `history-service/internal/cassrepo/messages_by_room.go`, `history-service/internal/cassrepo/thread_messages.go`
+- Modify: `docs/cassandra_message_model.md`, `docs/client-api.md`
+
+**Approach:** Mostly schema/struct simplification, but several test files reference `TargetUser` in fixtures or positive assertions — those must be cleaned up in the same change or the build breaks. Integration tests using testcontainers run the init CQL fresh on each run; they exercise the new schema automatically once the fixtures stop populating the dropped field.
+
+Before editing anything, enumerate every reference:
+
+```
+grep -rnE '\bTargetUser\b|\btarget_user\b' pkg/ history-service/ docker-local/ docs/
+```
+
+Capture the file list and confirm it matches the expected set below. Anything unexpected gets investigated before you keep going.
+
+- [ ] **Step 1: Drop `TargetUser` from the Go struct**
+
+`pkg/model/cassandra/message.go` — remove the line:
+
+```go
+TargetUser *Participant `json:"targetUser,omitempty" cql:"target_user"`
+```
+
+- [ ] **Step 2: Drop `target_user` from all four init CQL files**
+
+In each of:
+- `docker-local/cassandra/init/10-table-messages_by_room.cql`
+- `docker-local/cassandra/init/11-table-thread_messages_by_room.cql`
+- `docker-local/cassandra/init/12-table-pinned_messages_by_room.cql`
+- `docker-local/cassandra/init/13-table-messages_by_id.cql`
+
+Remove the `target_user FROZEN<"Participant">,` line.
+
+- [ ] **Step 3: Drop `target_user` from history-service `baseColumns`**
+
+`history-service/internal/cassrepo/messages_by_room.go:13` — remove `target_user, ` from the string.
+`history-service/internal/cassrepo/thread_messages.go:15` — remove `target_user, ` from the string.
+
+- [ ] **Step 4: Update `docs/cassandra_message_model.md`**
+
+Remove the `target_user FROZEN<"Participant">,` line from all four schema sections (around lines 79, 112, 138, 165).
+
+- [ ] **Step 5: Update `docs/client-api.md`**
+
+Remove the `targetUser` row from the messages schema table (around line 939).
+
+- [ ] **Step 6: Verify history-service docker-compose's inline CQL is consistent**
+
+`history-service/docker-local/docker-compose.yml` has an inline `target_user` declaration at lines 74 and 101 (a duplicate schema for the integration-test stack). Remove `target_user FROZEN<"Participant">,` from both blocks so the dev stack stays consistent with the canonical init files.
+
+- [ ] **Step 7: Clean up test fixtures that reference `TargetUser` / `target_user`**
+
+The Go struct field is gone after Step 1 — any test that still constructs `Message{TargetUser: ...}` or asserts on `.TargetUser` will fail to compile. Confirmed test sites (re-grep before editing to catch any added since):
+
+- `pkg/model/cassandra/message_test.go` — remove `TargetUser:` field set(s) from any `Message` fixture and remove any `assert.Equal/require.NotNil` on `.TargetUser`.
+- `history-service/internal/cassrepo/messages_by_id_integration_test.go` — remove `TargetUser:` from insert fixtures and `require.NotNil(t, msg.TargetUser)` + `assert.Equal(...)` blocks (around `:106-108`).
+- `history-service/internal/cassrepo/thread_messages_integration_test.go` — same shape (around `:253-255`).
+- Any other integration test that pre-seeds rows with a `target_user` CQL column — drop the column from the seed INSERT statement.
+
+Pattern: replace positive assertions on `.TargetUser` with… nothing. The field is gone; there's no "verify it's nil" assertion to write because gocql ignores absent columns and the struct no longer carries them.
+
+- [ ] **Step 8: Run the full test matrix**
+
+```
+make lint
+make test
+make test-integration
+```
+
+All green. Integration tests start fresh Cassandra containers with the updated init scripts; production schemas are not touched (ops/IaC migration is out of scope).
+
+- [ ] **Step 9: Commit**
+
+```
+git add pkg/model/cassandra/ docker-local/cassandra/init/ history-service/internal/cassrepo/ history-service/docker-local/docker-compose.yml docs/cassandra_message_model.md docs/client-api.md
+git commit -m "refactor(cassandra): drop unused target_user column from message schema"
+```
+
+# Part 6 — Phantom Org / User Request-Time Validation
+
+Spec: see `2026-05-19-org-to-individual-membership-upgrade-design.md` Part 6.
+
+## Task 15: Reject phantom org IDs and account names at the room-service boundary
+
+Both `member.add` and channel-`create` accepted phantom inputs and silently dropped them at the candidates pipeline — the worker then wrote a `room_members` row and fired a sys-msg for a zero-user org, and async-job results reported `success: true` for a typo'd account. Gate at request time with two new store methods so the synchronous RPC reply carries the error.
+
+- [ ] **Step 1: Extend `RoomStore` in `room-service/store.go`**
+
+```go
+// FindExistingOrgIDs returns the subset of orgIDs that match at least
+// one user via sectId or deptId. ...
+FindExistingOrgIDs(ctx context.Context, orgIDs []string) ([]string, error)
+
+// FindExistingAccounts returns the subset of accounts that have a
+// matching user document. ...
+FindExistingAccounts(ctx context.Context, accounts []string) ([]string, error)
+```
+
+Both no-op (`return nil, nil`) when input is empty so handlers can call them unconditionally without an empty-slice round trip.
+
+Run `make generate SERVICE=room-service` to regenerate `mock_store_test.go`.
+
+- [ ] **Step 2: Implement in `room-service/store_mongo.go`**
+
+`FindExistingOrgIDs` runs two parallel `Distinct` calls (one on `sectId`, one on `deptId`, each filtered by `$in: orgIDs`), unions the results into a set, returns the slice. Both queries ride the existing `(sectId, account)` / `(deptId, account)` compound indexes.
+
+`FindExistingAccounts` is a single `Distinct` on `account` with `$in: accounts`.
+
+- [ ] **Step 3: Add validation helpers in `room-service/handler.go`**
+
+Two methods on `*Handler`:
+
+```go
+func (h *Handler) validateOrgIDs(ctx context.Context, orgIDs []string) error
+func (h *Handler) validateAccountsExist(ctx context.Context, accounts []string) error
+```
+
+Each:
+1. No-op on empty input.
+2. Call the store method.
+3. If `len(existing) == len(input)` — done.
+4. Otherwise build a set of `existing`, iterate `input`, and return `fmt.Errorf("org %q: %w", id, errInvalidOrg)` or `fmt.Errorf("user %q: %w", a, errUserNotFound)` for the first missing entry.
+
+`errInvalidOrg` and `errUserNotFound` are already in `sanitizeError`'s allow-list — no helper changes needed.
+
+- [ ] **Step 4: Wire into `handleAddMembers` and `handleCreateRoomChannel`**
+
+Insert the calls immediately after the `allOrgs`/`allUsers` dedup step, before `CountNewMembers` and `publishToStream`:
+
+```go
+if err := h.validateOrgIDs(ctx, allOrgs); err != nil {
+ return nil, err
+}
+if err := h.validateAccountsExist(ctx, allUsers); err != nil {
+ return nil, err
+}
+```
+
+Order matters only for which sentinel surfaces first when both dimensions have phantom entries — orgs first by convention. Cheaper checks (bot rejection, restricted-channel, capacity) stay ahead so phantom validation only runs once the request has cleared the no-DB-needed guards.
+
+- [ ] **Step 5: Tests**
+
+Unit (`room-service/handler_test.go`):
+- `TestHandler_AddMembers_PhantomOrgRejected` — `Orgs: ["org-nope"]`, store returns empty, assert `errors.Is(err, errInvalidOrg)` and no publish.
+- `TestHandler_AddMembers_PartiallyInvalidOrgRejected` — mixed; whole request rejects.
+- `TestHandler_AddMembers_NoOrgsSkipsOrgValidation` — gomock controller fails if `FindExistingOrgIDs` is called for a users-only request.
+- `TestHandler_AddMembers_PhantomUserRejected` — `errUserNotFound`.
+- `TestHandler_AddMembers_NoUsersSkipsUserValidation` — symmetric guard.
+
+Add a shared helper at the top of `handler_test.go`:
+
+```go
+func expectAllAccountsExist(store *MockRoomStore) *gomock.Call {
+ return store.EXPECT().FindExistingAccounts(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(_ context.Context, accs []string) ([]string, error) { return accs, nil })
+}
+```
+
+12 existing happy-path tests that reach `CountNewMembers` (5 in member.add, 7 in create-channel/create-room) need an `expectAllAccountsExist(store)` line inserted between `GetRoom` / `GetUser` and `CountNewMembers`. Tests that fail earlier (DM-rejected, restricted-non-owner, name-required, direct-bot-rejected) do not.
+
+Integration (`room-service/integration_test.go`):
+- `TestMongoStore_FindExistingOrgIDs_Integration` — sectId+deptId set union, all-phantom, empty input, dept-only invariant.
+- `TestMongoStore_FindExistingAccounts_Integration` — matching subset, all-phantom, empty input.
+
+- [ ] **Step 6: Update `docs/client-api.md`**
+
+Add Members → Error response: note `"invalid org"` for phantom orgs and `"user not found"` for phantom accounts.
+
+Create Room → Error response: same note (channel branch only — DM/botDM paths don't go through these gates).
+
+- [ ] **Step 7: Verify**
+
+```
+make generate SERVICE=room-service
+make lint
+make test
+go vet -tags integration ./room-service/...
+```
+
+Rebuild `room-service` only — no other service has runtime code changes:
+
+```
+docker compose -f docker-local/compose.services.yaml up -d --build --no-deps room-service
+```
+
+Frontend has no hardcoded references to either error string; the existing error envelope renderer surfaces both `"invalid org"` and `"user not found"` to the user without changes.
+
+- [ ] **Step 8: Commit**
+
+```
+git add room-service/ docs/client-api.md docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md docs/superpowers/plans/2026-05-19-member-add-improvements-plan.md
+git commit -m "feat(room-service): reject phantom org IDs and accounts at request time"
+```
+
+# Part 7 — PR #171 Follow-up Findings
+
+Spec: see `2026-05-19-org-to-individual-membership-upgrade-design.md` Part 7. Two review threads from `@mliu33` on PR #171 (merged into `main`, this branch already rebased onto it).
+
+## Task 16: Pass room key pair into `buildAndFanOutRoomKey` (Finding 1)
+
+- [ ] **Step 1: Change the function signature in `room-worker/handler.go:1792`**
+
+```go
+func (h *Handler) buildAndFanOutRoomKey(ctx context.Context, roomID string, pair *roomkeystore.VersionedKeyPair, users []model.User) error {
+ if pair == nil {
+ roomkeymetrics.KeyAbsentErrors.Add(ctx, 1)
+ return newPermanentAbsent("room key absent for %s", roomID)
+ }
+ // ...build event + fanOutKey as today...
+}
+```
+
+Drop the `keyStore.Get` call at line 1793. Keep the nil check as a defensive guard.
+
+- [ ] **Step 2: Thread `pair` through `finishCreateRoom`**
+
+`finishCreateRoom` at `room-worker/handler.go:1363` is the only site that calls `buildAndFanOutRoomKey` on the create path. Add a `pair *roomkeystore.VersionedKeyPair` parameter, pass it directly to `buildAndFanOutRoomKey` at line 1464.
+
+Update both callers in `processCreateRoom`:
+- `room-worker/handler.go:1258` (DM/BotDM branch) — pass `pair` already in scope from L1188.
+- `room-worker/handler.go:1360` (channel branch via `processCreateRoomChannel`) — `processCreateRoomChannel` gets a `pair` parameter too; pass through from `processCreateRoom`.
+
+- [ ] **Step 3: `processAddMembers` fetches the pair before the fan-out call**
+
+At `room-worker/handler.go:993-997`:
+
+```go
+if len(newSubUsers) > 0 {
+ pair, err := h.keyStore.Get(ctx, req.RoomID)
+ if err != nil {
+ roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Get")))
+ return fmt.Errorf("get room key for fan-out: %w", err)
+ }
+ if err := h.buildAndFanOutRoomKey(ctx, req.RoomID, pair, newSubUsers); err != nil {
+ return fmt.Errorf("fan out room key: %w", err)
+ }
+}
+```
+
+`buildAndFanOutRoomKey`'s internal nil check handles the absent case; no separate `if pair == nil` here.
+
+- [ ] **Step 4: Update `TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite`**
+
+At `room-worker/handler_test.go:3324`:
+- Drop the `keyStore.EXPECT().Get(...)` expectation.
+- Pass `keyPair` directly as the new third argument:
+ ```go
+ err := h.buildAndFanOutRoomKey(context.Background(), "room-1", keyPair, users)
+ ```
+- The `keyStore` mock can be dropped entirely from this test (no `Get` call left).
+
+Other tests that exercise `processCreateRoom` / `processAddMembers` end-to-end will still see one `keyStore.Get` per request (the existing gate-Get); they should not need new mocks.
+
+## Task 17: Drop `KeyGenerated` / `KeyRotated` success counters (Finding 2)
+
+- [ ] **Step 1: Delete the four emit sites**
+
+- `room-worker/handler.go:347` — `roomkeymetrics.KeyGenerated.Add(ctx, 1)` (no-prior path)
+- `room-worker/handler.go:356` — `roomkeymetrics.KeyGenerated.Add(ctx, 1)` (ErrNoCurrentKey fallback)
+- `room-worker/handler.go:362` — `roomkeymetrics.KeyRotated.Add(ctx, 1)` (rotate success)
+- `room-service/handler.go:369` — `roomkeymetrics.KeyGenerated.Add(ctx, 1)` (create-time gen success)
+
+- [ ] **Step 2: Remove the declarations from `pkg/roomkeymetrics/metrics.go`**
+
+Delete:
+- `KeyGenerated metric.Int64Counter` (line 15)
+- `KeyRotated metric.Int64Counter` (line 17)
+- The two `init()` blocks that register them (lines 39-45 and 47-53).
+
+Keep `FanoutErrors`, `ValkeyErrors`, `KeyAbsentErrors` as-is.
+
+- [ ] **Step 3: Verify**
+
+```
+make lint
+make test
+```
+
+No tests reference these counters (verified — grep on `_test.go` returns no hits for `KeyGenerated` / `KeyRotated`).
+
+## Task 18: Verify combined Part 7 work
+
+- [ ] **Step 1: Full local check**
+
+```
+make lint
+make test
+go vet -tags integration ./room-worker/... ./room-service/...
+```
+
+- [ ] **Step 2: Rebuild affected services**
+
+```
+docker compose -f docker-local/compose.services.yaml up -d --build --no-deps room-worker room-service
+```
+
+- [ ] **Step 3: Post the two GitHub thread replies**
+
+Each reply text is in the spec, Part 7. Post on the original PR #171 threads.
+
+- [ ] **Step 4: Commit**
+
+```
+git add room-worker/handler.go room-worker/handler_test.go room-service/handler.go pkg/roomkeymetrics/metrics.go docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md docs/superpowers/plans/2026-05-19-member-add-improvements-plan.md
+git commit -m "refactor(room-worker): address PR #171 follow-up review findings"
+```
diff --git a/docs/superpowers/spec.md b/docs/superpowers/spec.md
index 6c46b8f7d..bb9f31bc1 100644
--- a/docs/superpowers/spec.md
+++ b/docs/superpowers/spec.md
@@ -149,7 +149,6 @@ room-service
| ID | string | `id` | `_id` |
| Name | string | `name` | `name` |
| Type | RoomType | `type` | `type` |
-| CreatedBy | string | `createdBy` | `createdBy` |
| SiteID | string | `siteId` | `siteId` |
| UserCount | int | `userCount` | `userCount` |
| CreatedAt | time.Time | `createdAt` | `createdAt` |
diff --git a/docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md b/docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md
new file mode 100644
index 000000000..96c0bb502
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-org-to-individual-membership-upgrade-design.md
@@ -0,0 +1,766 @@
+# Member-Add Improvements + DM-Already-Exists Frontend Fix
+
+Three related changes, packaged together because Parts 1 and 2 share the same pipeline (`pkg/pipelines/member.go`) and the same candidate-selection path in `room-worker`; Part 3 sits alongside as a small frontend fix uncovered while investigating one of the user's reports.
+
+1. **Part 1** — bug fix for the silent no-op when an already-org-subscribed user is added individually.
+2. **Part 2** — new feature: accept `deptId` values inside the `orgs: [...]` field, alongside the existing `sectId` semantics.
+3. **Part 3** — frontend fix: skip the wait-for-summaries loop in `CreateRoomDialog` when the backend reports the DM already exists, so the dialog navigates directly to the existing room instead of risking the 3-second "taking longer than expected" banner on a session-start race.
+
+---
+
+# Part 1 — Fix: Add-Member Silently No-ops When Target Already Subscribed Via Org
+
+## Problem
+
+When a user is already in a channel as part of an organization (their subscription was created by a prior `member.add` with `orgs: [...]`), a follow-up `member.add` that lists that user under `users: [...]` is silently accepted but does nothing meaningful — the user remains an org-only member.
+
+Concretely, after the bug:
+- `subscriptions` doc for the user exists (unchanged from the org add).
+- `room_members` has a `member.type="org"` row for the org, but **no `member.type="individual"` row for the user**.
+- The remove-member flow's `HasIndividualMembership` / `HasOrgMembership` distinction (`room-service/handler.go:500`) treats the user as org-only, blocking individual removal/leave operations with "org members cannot leave individually" — even though the operator explicitly added them as an individual.
+
+## Root cause
+
+`pkg/pipelines/member.go:50-65` — `GetNewMembersPipeline` filters candidates by the *existence* of a `subscriptions` document, with no awareness of `room_members.member.type`. Both `room-service.CountNewMembers` and `room-worker.ListNewMembers` use this pipeline.
+
+When the target user already has a subscription (from the prior org add), the pipeline filters them out. `room-worker.processAddMembers` then hits the `len(accounts) == 0` early return at `room-worker/handler.go:714-716`, so the individual `room_members` row is never created.
+
+The pipeline answers "does any subscription exist?" when, for individually-targeted accounts, the question should be "does an *individual* `room_members` row exist?"
+
+## Design
+
+Replace the filter-on-subscription pipeline (for `roomID != ""`) with a pipeline that returns each candidate plus two flags. The worker then decides what to write based on those flags.
+
+### New pipelines
+
+`pkg/pipelines/member.go` gains two sibling pipelines that share a private match-stage helper. Splitting capacity-counting from full-candidate-resolution avoids paying for the `room_members` lookup on the capacity path (room-service runs `CountNewMembers` on every add request).
+
+```go
+// GetNewMembersPipeline counts net-new subscriptions; caller appends $count
+// (capacity check) or $group (worker candidate resolution). Existing API,
+// extended with dept-aware $or as documented later in this section.
+func GetNewMembersPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) bson.A
+
+// GetAddMemberCandidatesPipeline returns per-candidate {account, hasSubscription, hasIndividualRoomMember} for the worker.
+func GetAddMemberCandidatesPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) (bson.A, error)
+```
+
+Both pipelines:
+- Reuse a private `matchCandidates(orgIDs, directAccounts, excludeAccount)` helper for the `$match` stage (org/account union, bot exclusion, optional excludeAccount).
+- Use the existing `(roomId, u.account)` unique index on `subscriptions` for the subscriptions lookup.
+
+`GetAddMemberCandidatesPipeline` additionally:
+- Uses the `users` collection's `_id` (passed via `let: {uid: "$_id"}`) to drive the `room_members` lookup as `member.type=="individual" && member.id == $$uid`. **This pairs with the existing unique index `(rid, member.type, member.id)`**, so the lookup is index-covered and constant-time per candidate. Filtering on `member.account` instead would force a scan of every individual `room_members` row for the room — quadratic in (candidates × room size). The output `account` field is still projected from `$account` for the worker's bookkeeping.
+- Projects to `{ account, hasSubscription, hasIndividualRoomMember }` where each flag is `{ "$gt": [{ "$size": "$" }, 0] }`.
+
+Both require `roomID != ""` (panic on empty — the existence checks are meaningless without a room). The create-room capacity-check path keeps using the existing `GetNewMembersPipeline` (which takes `roomID == ""` and skips the subscriptions lookup, since no subs can exist for a not-yet-created room).
+
+### New worker store method
+
+`room-worker/store.go` and `store_mongo.go`:
+
+```go
+type AddMemberCandidate struct {
+ Account string
+ HasSubscription bool
+ HasIndividualRoomMember bool
+}
+
+func (s *MongoStore) ListAddMemberCandidates(
+ ctx context.Context,
+ orgIDs, directAccounts []string,
+ roomID string,
+) ([]AddMemberCandidate, error)
+```
+
+Implementation: run the new pipeline against the `users` collection; decode straight into `[]AddMemberCandidate`.
+
+The old `ListNewMembers` method is removed — the worker is its only caller.
+
+### Worker handler changes
+
+`room-worker/handler.go` `processAddMembers` is restructured:
+
+1. Call `h.store.ListAddMemberCandidates(ctx, req.Orgs, req.Users, req.RoomID)`.
+2. Compute the `writeIndividuals` gate as today: `writeIndividuals = len(req.Orgs) > 0 || hadOrgsBefore`. This preserves the existing convention that individual `room_members` rows are only tracked once orgs are involved (pre-orgs rooms keep using the subscription itself as the source of truth).
+3. Compute three derived sets:
+ - **`needSub`** = candidates where `!HasSubscription` — these go into `BulkCreateSubscriptions`.
+ - **`needIndividualRoomMember`** = candidates where `Account ∈ req.Users` && `!HasIndividualRoomMember` **and `writeIndividuals == true`** — these get a `member.type="individual"` row.
+ - **Org rows** = unchanged (one per `req.Orgs` entry).
+4. Early-return only when **all three** sets are empty (truly nothing to do).
+5. `FindUsersByAccounts` is called for the union of `needSub` and `needIndividualRoomMember` accounts (we need `user.ID` for the `room_members.member.id` field even on the upgrade path).
+6. Subscriptions are built and inserted only for `needSub`. The `IsDuplicateKeyError` swallow in `BulkCreateSubscriptions` stays as a defense-in-depth against JetStream redelivery, but is no longer load-bearing for the org→individual upgrade path.
+7. Individual `room_members` rows are built for `needIndividualRoomMember`, regardless of whether the sub was just inserted or already existed.
+8. The backfill loop at `room-worker/handler.go:836-867` (first-org backfill of existing subscribers) is unaffected — it runs only when `len(req.Orgs) > 0 && !hadOrgsBefore`, a different trigger. The set we backfill is "existing subscribers minus accounts we already processed in this request"; under the new design that "already processed" set is `needSub ∪ needIndividualRoomMember` (anyone whose individual row we're writing now, whether they got a new sub or just an upgrade).
+
+For the bug scenario specifically (alice already in via org, now added individually), `hadOrgsBefore == true` so `writeIndividuals == true` and the gate is naturally satisfied.
+
+### room-service changes
+
+`room-service/store.go` + `store_mongo.go`: `CountNewMembers` continues to use `GetNewMembersPipeline` (which natively handles both `roomID == ""` and `roomID != ""`) and appends `$count: "n"` as the terminal stage.
+
+Public signature and return type are unchanged. Capacity semantics preserved: re-adding an org-only user as an individual produces a count of 0 (no new sub), so the user doesn't double-count against `maxRoomSize`. The lite pipeline skips the `room_members` lookup that the worker path needs, so capacity checks stay cheap.
+
+The create-room path (`CountNewMembers` called with `roomID == ""`) keeps the old `GetNewMembersPipeline`.
+
+### Behavior matrix
+
+| Pre-state | Request | Sub written? | Individual row written? | Org row written? |
+|--------------------------------------------|--------------------------|--------------|-------------------------|------------------|
+| alice not in room | add alice individually | yes | yes | — |
+| alice not in room | add org-1 (contains alice) | yes | no | yes (for org-1) |
+| alice in room via org-1 only | add alice individually | no (skip) | **yes (fix)** | — |
+| alice in room via org-1 + individual | add alice individually | no | no | — |
+| alice in room via individual only | add org-1 (contains alice) | no | no | yes (for org-1) |
+| alice in room via org-1 | add org-2 (contains alice) | no | no | yes (for org-2) |
+
+### Domain field semantics on upgrade
+
+When alice is upgraded org → individual, her existing subscription's `JoinedAt` and `HistorySharedSince` are **not** touched. The individual `room_members` row gets `Ts = acceptedAt` (the new request's accepted time), which is the right value for "when did individual membership begin." This matches the system's existing model where the subscription represents membership-into-the-room and `room_members` represents membership-source rows.
+
+## Testing
+
+### Unit tests (`room-worker/handler_test.go`)
+
+Table-driven cases against `processAddMembers` covering the matrix above. Key new cases:
+
+- **Org→individual upgrade**: pre-state has subscription + org `room_members` row; request lists user under `Users`; assert `BulkCreateSubscriptions` is **not** called (or called with empty slice), `BulkCreateRoomMembers` is called with exactly one `member.type="individual"` row.
+- **Mixed add**: request lists two users — one truly new, one already org-subscribed; assert sub is created only for the new one, individual rows are created for both.
+- **No-op add**: request lists a user already both individually and org-subscribed; assert handler returns without writes.
+- **Duplicate org add**: request lists same org again; assert org `room_members` insert is attempted (existing unique-index dedupe still applies; no change there).
+
+### Unit tests (`room-service/handler_test.go`)
+
+`CountNewMembers` is mocked at the store boundary, so room-service tests don't need pipeline-level changes. Add one regression test that confirms the capacity-check path still rejects when `newCount + room.UserCount > maxRoomSize`.
+
+### Integration tests
+
+`room-worker/integration_test.go`:
+- Seed alice with a subscription and an org `room_members` row.
+- Run `processAddMembers` with `Users: ["alice"]`.
+- Assert: exactly one subscription doc for alice in the room (no duplicate), and exactly one individual `room_members` row for alice.
+
+`room-service/integration_test.go`:
+- Same pre-state.
+- Call `CountNewMembers(orgIDs=nil, directAccounts=["alice"], roomID)`.
+- Assert the result is 0 (capacity unchanged).
+
+### Coverage gate
+
+The CLAUDE.md 80% minimum applies; the new pipeline + store method should be at 90%+ given they're core business logic.
+
+## Out of scope
+
+- **Cross-site (OUTBOX) replay semantics.** The bug exists in the local `room-worker` path; cross-site `MemberAddEvent` handling uses a different code path that doesn't go through `ListNewMembers`. A spot-check of that path will be part of implementation, but a fix there (if needed) is a separate spec.
+- **`docs/client-api.md`.** The wire request/response shapes for `member.add` are unchanged. The CLAUDE.md client-API gate doesn't apply.
+- **Backfill of already-broken state.** Existing channels where alice is org-only but the operator intended individual membership won't auto-heal; a remediation job is out of scope. The operator can re-trigger `member.add` with `Users: ["alice"]` after this fix ships and it will create the missing row.
+
+---
+
+# Part 2 — Feature: Accept DeptId in `Orgs` Matching
+
+## Motivation
+
+Today, the `orgs: [...]` field in `member.add` and `room.create` is interpreted strictly as a list of `sectId`s — the pipeline at `pkg/pipelines/member.go:31` filters `users` on `sectId IN orgIDs`. Operators want to onboard whole departments without enumerating every constituent section. The fix accepts a `deptId` anywhere a `sectId` is accepted, with no API surface change.
+
+## Domain assumption (confirmed)
+
+In the operator's data, sectIds are unique across departments. When an ID exists as both a deptId (for some users) and a sectId (for others), the dept interpretation is a superset of the sect interpretation — every `sectId=X` user is also `deptId=X`. This lets us prefer the dept interpretation on overlap without losing members. This is a property of the source HR/directory data, not enforced by the chat system.
+
+## User model changes
+
+`pkg/model/user.go`:
+
+```go
+type User struct {
+ ID string `json:"id" bson:"_id"`
+ Account string `json:"account" bson:"account"`
+ SiteID string `json:"siteId" bson:"siteId"`
+ SectID string `json:"sectId" bson:"sectId"`
+ SectName string `json:"sectName" bson:"sectName"`
+ SectTCName string `json:"sectTCName" bson:"sectTCName"` // NEW
+ DeptID string `json:"deptId" bson:"deptId"` // NEW
+ DeptName string `json:"deptName" bson:"deptName"` // NEW
+ DeptTCName string `json:"deptTCName" bson:"deptTCName"` // NEW
+ EngName string `json:"engName" bson:"engName"`
+ ChineseName string `json:"chineseName" bson:"chineseName"`
+ EmployeeID string `json:"employeeId" bson:"employeeId"`
+}
+```
+
+The new fields are populated by the external HR / directory sync that already owns the `users` collection (no production code in this repo writes to `users`). Existing user docs that pre-date the sync extension won't have dept fields populated; dept-based adds for those users return 0 candidates — same outcome as adding an unknown `orgId` today.
+
+## Pipeline changes
+
+Both `GetNewMembersPipeline` (create-room) and the new `GetAddMemberCandidatesPipeline` introduced in Part 1 extend their `$match.$or`:
+
+```go
+if len(orgIDs) > 0 {
+ orFilter = append(orFilter, bson.M{"sectId": bson.M{"$in": orgIDs}})
+ orFilter = append(orFilter, bson.M{"deptId": bson.M{"$in": orgIDs}}) // NEW
+}
+```
+
+Per-account dedup via the terminal `$addToSet $account` (or, for `GetAddMemberCandidatesPipeline`, grouping by `account`) handles the case where a single user matches both clauses (e.g., `user.sectId=X` and `user.deptId=Y`, both in `orgIDs`) — only one candidate row per account.
+
+No tiebreak is needed at this stage. The tiebreak lives in the read paths below.
+
+## Read-path tiebreak: prefer dept on overlap
+
+The room-member display and remove-org sys-message resolve a single rendered string per `member.id` row. Two steps:
+
+1. **Pick the winning interpretation.** Per the domain assumption, when `member.id` exists as both a deptId and a sectId, the dept's `(deptName, deptTCName)` pair wins.
+2. **Format the rendered name.** The chosen pair is concatenated as `name + " " + tcName`, mirroring the existing `displayName(user) = engName + " " + chineseName` convention in `room-worker/sysmsg.go:10-25`. Empty-component fallbacks follow the same pattern (if either is empty, return the non-empty one; if both empty, the caller's "no name" branch fires).
+
+The combined string is what appears in member-list display rows, in the system-message `Content` field, and in the `MemberRemoved.SectName` structured payload — same shape consumers see today, with broadened semantics.
+
+The existing `displayName(user)` helper in `room-worker/sysmsg.go:10-25` already encodes the same "combine two names with a space, fall back to the non-empty one, then fall back to a third value" pattern (today the third value is `user.Account`). Extract the shared core into a new package `pkg/displayfmt` so both `room-worker` (sys-messages) and `room-service` (member-list enrichment) can use one canonical implementation:
+
+```go
+// pkg/displayfmt/combine.go
+package displayfmt
+
+import "strings"
+
+// CombineWithFallback joins first and second with a space; returns the non-empty side or fallback when both are empty.
+func CombineWithFallback(first, second, fallback string) string {
+ first = strings.TrimSpace(first)
+ second = strings.TrimSpace(second)
+ switch {
+ case first == "" && second == "":
+ return fallback
+ case first == "":
+ return second
+ case second == "":
+ return first
+ case first == second:
+ return first
+ default:
+ return first + " " + second
+ }
+}
+```
+
+`room-worker/sysmsg.go` collapses its existing 16-line `displayName` to a one-line wrapper and adds `displayOrg`:
+
+```go
+func displayName(u *model.User) string { return displayfmt.CombineWithFallback(u.EngName, u.ChineseName, u.Account) }
+func displayOrg(name, tcName, orgID string) string { return displayfmt.CombineWithFallback(name, tcName, orgID) }
+
+func formatRemovedOrg(name, tcName, orgID string) string {
+ return quoted(displayOrg(name, tcName, orgID)) + " has been removed from the channel"
+}
+```
+
+`room-service/store_mongo.go ListRoomMembers` uses `displayfmt.CombineWithFallback` directly in its decode loop (see the enrichment section below).
+
+The MongoDB pipeline does NOT replicate the combine logic — it returns the raw `{name, tcName}` pair and the Go side combines once on read. This eliminates the BSON↔Go duplication and saves pipeline stages on the member-list hot path.
+
+### Enrichment join (`room-service/store_mongo.go:451-472`)
+
+The `_orgMatch` `$lookup`'s inner pipeline stays at 3 stages — match → addFields → group — matching today's stage count. Pipeline emits raw `{name, tcName, memberCount}` and the Go decode applies `combineWithFallback`. This avoids duplicating the helper logic in BSON and is a strict perf win on the member-list hot path:
+
+```go
+pipeline: bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$$mtyp", "org"}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ bson.M{"$eq": bson.A{"$sectId", "$$orgId"}},
+ }},
+ }}}},
+ bson.M{"$addFields": bson.M{
+ // Per-row name pair from whichever side matched; $max(_isDept) in the $group then picks dept-first.
+ "_isDept": bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ "_name": bson.M{"$cond": bson.A{bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptName", "$sectName"}},
+ "_tcName": bson.M{"$cond": bson.A{bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptTCName", "$sectTCName"}},
+ }},
+ bson.M{"$group": bson.M{
+ // $max on _isDept tells the Go decoder whether any dept row exists; pair with the matching name.
+ "_id": nil,
+ "isDept": bson.M{"$max": "$_isDept"},
+ "deptName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_name", nil}}},
+ "deptTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_tcName", nil}}},
+ "sectName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_name"}}},
+ "sectTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_tcName"}}},
+ "memberCount": bson.M{"$sum": 1},
+ }},
+},
+```
+
+The outer `$set display` block (`store_mongo.go:475-489`) pulls **raw** name pairs into a temporary `display.orgRaw` sub-doc instead of constructing the combined string in the pipeline:
+
+```go
+"orgRaw": bson.M{"$arrayElemAt": bson.A{"$_orgMatch", 0}},
+"memberCount": bson.M{"$arrayElemAt": bson.A{"$_orgMatch.memberCount", 0}},
+```
+
+`ListRoomMembers` Go decode loop then resolves the final string with the dept-first preference and orgId fallback, reusing the same `combineWithFallback` helper as the worker:
+
+```go
+for i := range members {
+ if members[i].Member.Type != model.RoomMemberOrg {
+ continue
+ }
+ raw := members[i].Display.OrgRaw
+ name, tcName := raw.SectName, raw.SectTCName
+ if raw.IsDept && raw.DeptName != "" {
+ name, tcName = raw.DeptName, raw.DeptTCName
+ }
+ members[i].Display.SectName = combineWithFallback(name, tcName, members[i].Member.ID)
+}
+```
+
+The field name `display.sectName` stays unchanged for wire stability; its semantics broaden to "the resolved org display name (dept-or-sect, `name + " " + tcName`, or the raw orgId when both names are empty)." Both paths (sys-message in worker, member-list in service) emit byte-identical strings for the same input because they share the same Go helper.
+
+**Net stage count:** unchanged at 3 inner stages (vs. today's 3). All BSON-Go duplication is eliminated.
+
+### List-org-members RPC (`room-service/store_mongo.go:707`)
+
+The `chat.user.{account}.request.orgs.{orgId}.members` endpoint (the "expand org" RPC the client calls to enumerate an org row in the member roster) was sectId-only. Under the broadened semantics it must resolve a deptId-added org just as it resolves a sectId-added org — otherwise the roster shows the org row but expansion returns `errInvalidOrg`.
+
+`ListOrgMembers` switches from `Find({sectId: orgId})` to:
+
+```go
+cursor, err := s.users.Find(ctx, bson.M{"$or": []bson.M{
+ {"sectId": orgID},
+ {"deptId": orgID},
+}}, opts)
+```
+
+This is the symmetric companion to the `GetSubscriptionWithMembership` / `GetUserWithMembership` dept-aware lookups landed earlier in this PR — same `$or` shape, same indexes covering both branches. Under the operator's domain assumption (dept ⊇ sect for any overlapping id), a deptId-added org returns the dept's full membership; a sectId-only org returns just the sect's members. The wire response shape and `errInvalidOrg` semantics are unchanged.
+
+Test coverage (integration, `room-service/integration_test.go::TestMongoStore_ListOrgMembers_Integration`):
+- `matches_by_deptId` — users with distinct `sectId` and `deptId`; query by `deptId` returns dept rows the sect-only filter would have missed.
+- `matches dept users when orgId equals deptId without parent sect match` — invariant case (`sectId == deptId` for dept users); query by that id returns only the dept user.
+
+### Remove-org name harvest (`room-worker/handler.go:514-523`)
+
+`OrgMemberStatus` (`room-worker/store.go:31`) drops `SectName` and gains a per-row resolved pair. The pipeline does the per-row name selection (sect-fields when the row matched sectId, dept-fields when it matched deptId); the handler does the org-level dept-first tiebreak:
+
+```go
+type OrgMemberStatus struct {
+ Account string
+ SiteID string
+ Name string // resolved per-row: deptName if IsDept else sectName
+ TCName string // resolved per-row: deptTCName if IsDept else sectTCName
+ IsDept bool // true if this row matched by deptId
+ HasIndividualMembership bool
+}
+```
+
+`GetOrgMembersWithIndividualStatus` (`room-worker/store_mongo.go:261-294`) extends the pipeline:
+- `$match: {$or: [{sectId: orgID}, {deptId: orgID}]}` — index-covered on both branches by `(sectId, account)` and the new `(deptId, account)`.
+- `$addFields` computes `isDept = (deptId == orgID)`, plus `name`/`tcName` via `$cond` on `isDept`.
+- `$lookup` into `room_members` with `let: {uid: "$_id"}` and the inner match `member.type=="individual" && member.id == $$uid` — index-covered by the existing unique `(rid, member.type, member.id)`. The current code matches on `member.account == "$$acct"` which is *not* index-covered; switch to `member.id` for constant-time per-user lookup.
+- `$project` keeps `account`, `siteId`, `name`, `tcName`, `isDept`, `hasIndividualMembership` — internal `$$` vars and the lookup arrays are explicitly dropped.
+
+`processRemoveOrg`'s name-harvest loop is replaced with a two-pass resolution that picks a single `(name, tcName)`:
+
+1. First pass — find any row with `IsDept == true`. If found, take `(Name, TCName)` from that row.
+2. Second pass — if no dept match, find first row (any). Take its `(Name, TCName)`.
+3. If `members` is empty (e.g., users were deleted between request and processing), the resolved pair is `("", "")`.
+
+The resolved pair feeds `formatRemovedOrg(name, tcName, req.OrgID)` for the sys-msg `Content`, and `displayOrg(name, tcName, req.OrgID)` populates `MemberRemoved.SectName`. The `orgId` fallback inside `displayOrg` guarantees the rendered output is never empty:
+
+- Both names present → `"name tcName"`
+- Only one present → that one
+- Both empty → `orgID` (the raw sectId or deptId from the request)
+
+**The existing `"missing SectName on all members"` permanent error at `room-worker/handler.go:521-523` is removed.** With the orgId fallback, there's no longer a need to fail an org-remove on missing names — the message degrades gracefully to showing the ID instead. The check is replaced with a `slog.Warn` if you want to surface the data-quality issue without failing the operation; alternatively, drop the log too. (Spec choice: emit the warn, since silent data-quality degradation is harder to notice.)
+
+## Sys-message rendering: `Message.Content` is the source of truth
+
+**Frontend renders system messages by displaying `Message.Content` directly.** The structured `SysMsgData` payload (carrying `MemberRemoved`) is preserved for non-rendering consumers (analytics, audit, future structured features) but is **not** the rendering source. This matches today's behavior — the change here only broadens what `Content` says, not how it's consumed.
+
+Concretely, for an org-remove where the winning interpretation is `dept` with `deptName="Engineering"` and `deptTCName="工程部"`:
+
+- `Message.Content` = `"\"Engineering 工程部\" has been removed from the channel"` — what the user sees.
+- `Message.SysMsgData` = `MemberRemoved{OrgID: "...", SectName: "Engineering 工程部", RemovedUsersCount: N}` — structured copy of the same combined string in the existing `SectName` field.
+
+### `MemberRemoved` payload shape
+
+`model.MemberRemoved` (`pkg/model/member.go`) — **no new fields**. The existing `SectName` field carries the combined `displayOrg(name, tcName)` string:
+
+```go
+type MemberRemoved struct {
+ User *SysMsgUser `json:"user,omitempty"`
+ OrgID string `json:"orgId,omitempty"`
+ SectName string `json:"sectName,omitempty"` // existing — now carries displayOrg(name, tcName)
+ RemovedUsersCount int `json:"removedUsersCount"`
+}
+```
+
+This keeps the wire schema stable. The field name's semantics drift (it's no longer guaranteed to be just the sectName — it's the resolved org display string), and a future cleanup PR can rename it; tracked in "Out of scope" below.
+
+## Behavior matrix
+
+| `req.Orgs` entry X is… | Candidates returned | Name resolved at read-time |
+|---|---|---|
+| A sectId only | All users with `sectId == X` | `sectName` |
+| A deptId only | All users with `deptId == X` | `deptName` |
+| Both (per domain assumption) | Union dedup'd by `account` — since dept ⊇ sect, this equals all users with `deptId == X` | `deptName` (prefer-dept wins) |
+| Neither (unknown) | 0 candidates | — (no row written) |
+
+## Callers / paths touched by Part 2
+
+- `pkg/model/user.go` — new fields.
+- `pkg/pipelines/member.go` — `$or` extended; applies to both `GetNewMembersPipeline` and the new `GetAddMemberCandidatesPipeline` from Part 1.
+- `room-service/store_mongo.go:451-472, 475-489` — enrichment join + display fold (dept-preference, new TCName field).
+- `room-service/store_mongo.go:707` — `ListOrgMembers` `$or` over `(sectId, deptId)` so the `orgs.{orgId}.members` RPC resolves deptId-added orgs.
+- `room-worker/store.go` — `OrgMemberStatus` struct extended.
+- `room-worker/store_mongo.go:261-294` — `GetOrgMembersWithIndividualStatus` pipeline extended.
+- `room-worker/handler.go:508-523` — name-harvest loop with dept-first preference.
+- `room-worker/handler.go:618-622` — `MemberRemoved.SectName` is populated with the combined `displayOrg(name, tcName, orgID)` string instead of a raw sectName.
+- `room-service/store_mongo.go:63-71` (`EnsureIndexes`) — new composite index `(deptId, account)` on `users` to match the existing `(sectId, account)` index, so the extended `$or` predicate is index-covered on both branches.
+
+No changes needed to:
+- `room-service/handler.go` `classifyAndValidate` or `handleAddMembers` — `Orgs` field shape is unchanged.
+- `room_members` collection schema — `member.type` stays `"org"`; `member.id` stays the raw orgId string.
+- Cross-site `MemberAddEvent` (carries accounts only).
+- OIDC claim parsing — `users` doc population is external to this repo.
+
+## Testing (Part 2)
+
+**Unit — pipeline (`pkg/pipelines/member_test.go`)**:
+- New `GetNewMembersPipeline` test cases: orgId matching only a sectId, orgId matching only a deptId, orgId matching both (verify dedup).
+- Same matrix for `GetAddMemberCandidatesPipeline`.
+
+**Unit — room-service enrichment (`room-service/store_mongo_test.go` or via handler test)**:
+- Room with `member.id=X` and one user `(sectId=X, sectName="Sec")` → enriched `sectName == "Sec"`, no TCName.
+- Room with `member.id=X` and one user `(deptId=X, deptName="Dep", deptTCName="部门")` → enriched `sectName == "Dep"`, `sectTCName == "部门"`.
+- Room with `member.id=X` and overlap (one user `sectId=X`, one user `deptId=X`) → dept wins, `memberCount == 2`.
+
+**Unit — room-worker (`room-worker/handler_test.go`)**:
+- `processRemoveOrg` with all users `sectId=orgID`: sys-msg `SectName` is sect's name, `SectTCName` is sect's TC name.
+- `processRemoveOrg` with all users `deptId=orgID`: sys-msg `SectName` is dept's name, `SectTCName` is dept's TC name.
+- `processRemoveOrg` with mixed (one dept-matched user, one sect-only): sys-msg uses dept's `(name, tcName)`.
+- `processRemoveOrg` with every name empty: sys-msg `Content` and `SectName` fall back to `req.OrgID` (no error), a warning is logged.
+- `processRemoveOrg` with only `tcName` populated (no `name`): sys-msg uses `tcName` alone (no leading space).
+- Enrichment: room with an org member whose users have no `name`/`tcName` at all → `display.sectName` resolves to `member.id`.
+
+**Integration**:
+- Seed users where `users[0].sectId="X"` and `users[1].deptId="X"`.
+- `member.add` with `Orgs: ["X"]` → both users get subscriptions.
+- `member.remove` org-flow on X → sys-msg carries dept's name.
+
+## Performance and indexes
+
+Every new or modified query is verified against the existing indexes; nothing is left to a full-collection scan. The table below covers all read paths introduced or modified across Parts 1 and 2.
+
+| Pipeline / query | Filter | Index used | Notes |
+|---|---|---|---|
+| `GetCapacityCheckPipeline` `$match` | `sectId IN orgs OR deptId IN orgs OR account IN directAccounts` | `(sectId, account)` + new `(deptId, account)` + `account` | $or with both branches index-covered; MongoDB executes an index union. |
+| `GetCapacityCheckPipeline` `$lookup subscriptions` | `roomId == X && u.account == Y` | existing unique `(roomId, u.account)` | Index-covered. |
+| `GetAddMemberCandidatesPipeline` `$match` | same as above | same as above | Same. |
+| `GetAddMemberCandidatesPipeline` `$lookup subscriptions` | same as above | same as above | Same. |
+| `GetAddMemberCandidatesPipeline` `$lookup room_members` | `rid == X && member.type == "individual" && member.id == $$uid` | existing unique `(rid, member.type, member.id)` | Index-covered. Critical: uses `member.id` (user's `_id`), not `member.account`. |
+| `GetOrgMembersWithIndividualStatus` `$match` | `sectId == orgID OR deptId == orgID` | `(sectId, account)` + new `(deptId, account)` | Both branches index-covered; same index union as above. |
+| `GetOrgMembersWithIndividualStatus` `$lookup room_members` | `rid == X && member.type == "individual" && member.id == $$uid` | existing unique `(rid, member.type, member.id)` | Index-covered. Switched from `member.account` to `member.id` as part of this work. |
+| Enrichment `_orgMatch` `$lookup` (`room-service/store_mongo.go:451-472`) | `member.type == "org" && (sectId == $$orgId OR deptId == $$orgId)` | `(sectId, account)` + new `(deptId, account)` | $or both branches indexed; inner `$sort _isDept desc` + `$group $first` work on a small per-org candidate set (members of one org), not the whole users collection. |
+| `ListOrgMembers.Find` (`room-service/store_mongo.go:707`) | `sectId == orgID OR deptId == orgID` | `(sectId, account)` + new `(deptId, account)` | $or both branches index-covered; sorted by `account` ASC matches the leading prefix of both compound indexes after the equality on the leading key. |
+
+### Required new index
+
+`room-service/store_mongo.go` `EnsureIndexes`:
+
+```go
+if _, err := s.users.Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "deptId", Value: 1}, {Key: "account", Value: 1}},
+}); err != nil {
+ return fmt.Errorf("ensure users (deptId,account) index: %w", err)
+}
+```
+
+Mirrors the existing `(sectId, account)` index. Created in `EnsureIndexes` at service startup; idempotent.
+
+### Hot-path cost analysis
+
+- **`member.add` request** triggers two pipeline executions: `CountNewMembers` (room-service) and `ListAddMemberCandidates` (room-worker). The split design ensures the capacity-check path doesn't pay for the `room_members` lookup. Both are gated by the `users` `$match` filter so they only touch the candidate set (typically tens of users for a direct add, hundreds for an org add), not the whole `users` collection.
+- **`member.list` enrichment** (`ListRoomMembers` with `enrich=true`) is the most frequent hot path. Added work per org row: an extra `$or` branch in the inner `$lookup` `$match` (negligible — still indexed), plus per-org `$addFields` / `$sort` / `$group` / `$switch` stages operating on a small in-memory result set. For a room with N org rows the marginal cost stays O(N) rather than O(N × users).
+- **`member.remove` (org)** triggers one `GetOrgMembersWithIndividualStatus` pipeline. The room_members lookup switching from `member.account` to `member.id` removes a quadratic-in-room-size scan that was a latent slow-query risk in the current code — net performance improvement over today's behavior, not just a no-regression.
+
+### Pipeline complexity audit
+
+The new `GetAddMemberCandidatesPipeline` runs **4 stages per candidate** (`$match` → 2× `$lookup` → `$project`), the same operator count as the existing `GetNewMembersPipeline` for the non-empty-roomID case. No N+1 patterns; no per-row Go-side queries.
+
+The enrichment `_orgMatch` inner pipeline stays at **3 stages** (`$match` → `$addFields` → `$group`) — the same count as before. The `$group` widens to carry `isDept` and parallel `deptName`/`deptTCName`/`sectName`/`sectTCName` accumulators, but no stages are added. Combining the chosen branch happens once in Go (`displayfmt.CombineWithFallback`) on read, not in BSON. Each pipeline runs on the small per-org candidate set, so absolute time stays in the low-millisecond range for typical rooms.
+
+## Out of scope (Part 2)
+
+- **Backfilling existing User docs** with dept fields. Handled by the external HR/directory sync.
+- **OIDC claim extension** for `SectID`/`SectName`/`SectTCName`/`DeptTCName`. The current OIDC claim set only includes `DeptID`/`DeptName`, but `users` doc population doesn't go through OIDC anyway.
+- **Wire-schema rename** of `MemberRemoved.SectName` → `OrgName` (and `display.sectName` → `display.orgName`). Tracked as a follow-up cleanup; the field name semantics drift but the wire stays stable for this PR.
+- **Frontend rendering** of `SectTCName` / TCName variants in the member list and system messages. Backend exposes the fields; frontend uptake is separate.
+- **Persistence of resolved name in `room_members`**. The room_members row keeps `member.id` only; the name is resolved on every read. This matches the existing pattern and the user's explicit "`RoomMember` will remain the same. No change there." constraint.
+
+---
+
+# Part 3 — Frontend: Navigate Directly On DM-Already-Exists Reply
+
+## Problem
+
+When a user starts a DM with someone they already have a DM with, `CreateRoomDialog` occasionally shows the "Room creation is taking longer than expected. If it succeeds, the room will appear in your sidebar shortly — you can dismiss this dialog." banner before navigating, instead of opening the existing room immediately.
+
+The backend dedup is correct (`room-service/handler.go:272-278` returns `dmExistsError`; `replyDMExists` synchronously replies `{error: "dm already exists", roomId: ""}`). The frontend's `treatAsSuccess: isDMExistsReply` correctly short-circuits the async-result wait. The remaining gap is in the dialog's post-reply logic.
+
+## Root cause
+
+`CreateRoomDialog.jsx:87-105` treats both branches — genuine create and dedup — through the same flow:
+
+1. Call `createRoom(...)`, get back `sync` (either `{status:"accepted",roomId,roomType}` or `{error:"dm already exists",roomId}`).
+2. `setPendingRoom({id: roomId, ...})` — enters the wait-for-summaries state.
+3. `useEffect` (`:41-47`) watches `summaries` for a row with id matching `pendingRoom.id`. On match → `onCreated(pendingRoom); onClose()`.
+4. `useEffect` (`:61-70`) fires a 3-second timeout (`SUBSCRIPTION_WAIT_TIMEOUT_MS`); on expiry → sets the "taking longer than expected" error.
+
+For a genuine create that's the correct shape — the room doesn't exist in `summaries` yet, and `subscription.update` will land shortly and dispatch `ROOM_ADDED`, populating `summaries`. The match effect fires and the dialog closes.
+
+For the **dedup branch**, the room and subscription already exist. `summaries` should already contain the row from session start (`BUCKETS_LOADED`). In practice the match effect fires the same render tick and the dialog closes immediately — most users never see the banner. But the path is **brittle to any race** where `summaries` doesn't yet have that DM:
+
+- User opens Create dialog before `BUCKETS_LOADED` resolves.
+- Reconnect-rebuild of summaries races with the click.
+- Future regressions to `useRoomSubscriptions` initial-load timing.
+
+In those cases the 3-second timer wins and the user sees the banner for a room that already exists in their database.
+
+## Design
+
+Short-circuit the wait state on the dedup branch. The existing DM is guaranteed to exist server-side (the backend just confirmed it). The user's `subscriptions` and `summaries` will reflect it — and if they don't yet, they will momentarily; either way navigating directly to it is correct.
+
+### Code change
+
+`chat-frontend/src/components/MainApp/Sidebar/CreateRoomDialog/CreateRoomDialog.jsx`, `handleSubmit`:
+
+```jsx
+const { sync } = await createRoom(
+ nats,
+ { name: trimmedName, users: finalUsers, orgs: finalOrgs, channels: finalChannels },
+ { treatAsSuccess: isDMExistsReply }
+)
+const roomId = sync.roomId
+const displayName = trimmedName || finalUsers[0] || ''
+
+if (isDMExistsReply(sync)) {
+ // Dedup branch: server already confirmed the DM; skip the summaries-wait that can trip the 3s banner on a BUCKETS_LOADED race.
+ onCreated({ id: roomId, type: 'dm', siteId: user.siteId, name: displayName })
+ onClose()
+ return
+}
+
+const roomType = sync.roomType
+setPendingRoom({ id: roomId, type: roomType, siteId: user.siteId, name: displayName })
+```
+
+The post-dedup branch:
+- Drops the `pendingRoom` setter (no wait state needed).
+- Drops the `roomType` fallback (`sync.roomType || (isDMExistsReply(sync) ? 'dm' : undefined)`) since the only path that hits this case is `isDMExistsReply==true` and we hardcode `'dm'`. Note: this collapses the dedup-only-knows-roomId-not-type concern into "we always say `'dm'` on dedup." If the existing room is actually a `botDM`, the receiver (`ChatPage` / sidebar) will correct the type from the canonical subscription record. The DM/botDM distinction in the dialog's local `pendingRoom` is only used as a hint for the wait-state UI, which we're skipping.
+
+### Why not also fix this for channels?
+
+The dedup path only fires for DMs/botDMs (deterministic room IDs). Channels always get a fresh random ID per request and `room-service` never short-circuits — they always go through the async create path. So Part 3's logic is correctly scoped to the `isDMExistsReply(sync)` branch.
+
+### `onCreated` contract
+
+`onCreated({id, type, siteId, name})` is consumed by `Sidebar` / `MainApp` to set the active room. The shape here matches the genuine-create call exactly; the only behavior difference is that we don't enter the `pendingRoom` wait first. The downstream consumer doesn't care that no `ROOM_ADDED` will fire — it already has the room in summaries.
+
+## Testing
+
+`CreateRoomDialog.test.jsx`:
+- **Existing test** at `:139` passes today because `DEFAULT_SUMMARIES` (`:23-27`) already contains `{id: 'r-existing'}`, so the wait-for-summaries effect matches on the first render. Rewrite the test to render with `summaries: []` (override the default) so it would deadlock on `SUBSCRIPTION_WAIT_TIMEOUT_MS` under the pre-Part-3 code (failing the Red phase) and pass under Part 3's synchronous-navigate branch (Green).
+- **New test** — same `summaries: []` setup, additionally assert the "taking longer than expected" banner is **never** rendered (`queryByText` returns `null`, no fake timers needed since the dedup branch skips the timeout entirely).
+
+No backend tests change. Backend dedup behavior is unchanged.
+
+## Out of scope (Part 3)
+
+- **Channel/botDM dedup** — channels never dedup at room-service today; the backend would have to detect e.g. duplicate channel names per requester, which is a different feature.
+- **Refactor of the `pendingRoom` wait machinery** — the wait state is still correct for the genuine-create branch. We're skipping it on the dedup branch, not removing it.
+- **Frontend type-correction on dedup** — if a user creates what they think is a botDM but the dedup reply points at an existing regular DM (or vice versa), the dialog hardcodes `'dm'`. The canonical subscription record corrects this downstream. A more thoughtful "preserve sync.roomType when provided" tweak is plausible but the backend doesn't return `roomType` on the dedup reply today, so it's a backend-coupled improvement deferred to a follow-up.
+
+---
+
+# Part 4 — Remove `Room.CreatedBy` and rework the replay-equivalence check
+
+## Problem
+
+`Room.CreatedBy` is persisted (`bson:"createdBy"`), on the wire (`json:"createdBy"`), and surfaced in `chat-frontend/src/api/types.ts:110`. The frontend never reads it (the only write site is `fetchSidebarBuckets/index.ts:135`, which writes `""`). It IS used in `room-worker/handler.go` replay-equivalence checks at `:1141-1147` (async create) and `:1543-1551` (sync DM create) as one of four immutable identity fields compared after a duplicate-key on `CreateRoom`.
+
+The check is over-determined and incidentally breaks DM concurrent-creation. When users A and B simultaneously hit "Create DM" with each other (race past room-service's `FindDMSubscription` dedup), both workers compute the same deterministic room ID via `BuildDMRoomID`. Whichever wins the insert sets `CreatedBy = winnerID`; the loser hits duplicate-key, fetches the existing room, and the equivalence check fails with "room ID collision" because `existing.CreatedBy != room.CreatedBy` — even though the room IS the intended DM, just created a millisecond earlier by the counterpart.
+
+## Design
+
+Drop the field everywhere it appears (model, wire, frontend type, frontend write site, docs). Replace the comparison with a requester-subscription check that's strictly more correct.
+
+### Worker rewrite
+
+Both `processCreateRoom` (`:1130-1153`) and the sync-DM path (`:1535-1558`) call a new private helper to avoid duplicating the recovery logic:
+
+```go
+// reconcileRoomOnDuplicateKey verifies the existing room is structurally compatible and the requester is a member; one source of truth for both create paths.
+func (h *Handler) reconcileRoomOnDuplicateKey(ctx context.Context, want *model.Room, requesterAccount string) (*model.Room, error) {
+ existing, err := h.store.GetRoom(ctx, want.ID)
+ if err != nil {
+ return nil, fmt.Errorf("fetch on duplicate-key: %w", err)
+ }
+ if existing.Type != want.Type || existing.SiteID != want.SiteID {
+ return nil, newPermanent("room ID collision (existing type=%s site=%s; want %s/%s)",
+ existing.Type, existing.SiteID, want.Type, want.SiteID)
+ }
+ if _, err := h.store.GetSubscription(ctx, requesterAccount, want.ID); err != nil {
+ if errors.Is(err, model.ErrSubscriptionNotFound) {
+ return nil, newPermanent("room ID collision (requester %s not a member of existing room %s)",
+ requesterAccount, want.ID)
+ }
+ return nil, fmt.Errorf("check requester sub on duplicate-key: %w", err)
+ }
+ return existing, nil
+}
+```
+
+Each call site collapses to:
+
+```go
+if err := h.store.CreateRoom(ctx, room); err != nil {
+ if !mongo.IsDuplicateKeyError(err) {
+ return fmt.Errorf("create room: %w", err)
+ }
+ existing, err := h.reconcileRoomOnDuplicateKey(ctx, room, requester.Account)
+ if err != nil {
+ return err
+ }
+ room = existing
+}
+```
+
+The sync-DM path additionally preserves `acceptedAt = existing.CreatedAt` after the assignment — that's the only divergence and stays at the caller.
+
+The `Name` comparison drops — it was load-bearing only as another identity check; the new `(Type, SiteID, requester-sub-exists)` triple is strictly more correct and decouples the equivalence check from future name-mutation flows.
+
+### Removal sites
+
+- `pkg/model/room.go` — drop `CreatedBy` field.
+- `room-worker/handler.go` — remove `CreatedBy: requester.ID` from both `room := &model.Room{…}` literals; rewrite the duplicate-key blocks as above.
+- `chat-frontend/src/api/types.ts:110` — drop `createdBy: string` from the `Room` type.
+- `chat-frontend/src/api/fetchSidebarBuckets/index.ts:135` — drop the `createdBy: ''` write.
+- `docs/client-api.md` — remove the row + example occurrences.
+
+### Testing
+
+Add an integration test for the previously-broken DM concurrent-create case in `room-worker/integration_test.go`: pre-insert a room with two pre-existing subs (counterpart already raced to create), then run `processCreateRoom` for the requester's request. Assert: no error, no duplicate sub, no duplicate room.
+
+# Part 5 — Remove `target_user` Cassandra column
+
+## Problem
+
+`target_user FROZEN<"Participant">` exists in four Cassandra tables (`messages_by_room`, `messages_by_id`, `thread_messages_by_room`, `pinned_messages_by_room`) and as `Message.TargetUser *Participant` in `pkg/model/cassandra/message.go:81`. It's read by history-service via `baseColumns` in `internal/cassrepo/{messages_by_room,thread_messages}.go`. It's documented in `docs/client-api.md:939` and `docs/cassandra_message_model.md`.
+
+**It is never written.** Greppable: `grep -rn "TargetUser:" /home/user/chat/` outside the struct declaration yields zero hits. All reads return NULL.
+
+## Design
+
+Remove the column and all references:
+- `pkg/model/cassandra/message.go` — drop `TargetUser` field.
+- `docker-local/cassandra/init/10-table-messages_by_room.cql`, `11-table-thread_messages_by_room.cql`, `12-table-pinned_messages_by_room.cql`, `13-table-messages_by_id.cql` — drop the column declaration.
+- `history-service/internal/cassrepo/messages_by_room.go` + `thread_messages.go` — remove `target_user` from `baseColumns`.
+- `docs/cassandra_message_model.md` — the source-of-truth schema (4 sections).
+- `docs/client-api.md:939` — drop the row.
+
+Production schema migration (`ALTER TABLE … DROP target_user`) is owned by ops/IaC; the dev init scripts ship the new schema for fresh setups. Reads against existing production tables continue to work — gocql ignores columns not declared in the struct.
+
+# Part 6 — Phantom Org / User Request-Time Validation
+
+## Problem
+
+Both `chat.user.{account}.request.room.{roomID}.{siteID}.member.add` and `chat.user.{account}.request.rooms.create` (channel branch) accepted requests carrying org IDs or account names with no backing user document. The candidates aggregation (`GetAddMemberCandidatesPipeline` / `GetNewMembersPipeline`) is a join on `users`, so phantom inputs silently produced an empty candidate set — `CountNewMembers` returned 0 for those entries, the request published to the canonical stream, and the room-worker's `req.Orgs` loop (`room-worker/handler.go:891-898` and `:1319-1325`) wrote a `room_members` row plus fired a `members_added` system message for an org with zero backing users. Phantom user accounts were silently dropped at the candidates pipeline; the async-job reply still reported `success: true`.
+
+## Design
+
+Reject at the room-service request boundary so the synchronous RPC reply carries the error and nothing reaches the canonical stream. Two new store methods on `RoomStore`:
+
+- `FindExistingOrgIDs(ctx, orgIDs []string) ([]string, error)` — returns the subset of `orgIDs` that match at least one user via `sectId` or `deptId`. Two parallel `Distinct` calls — one on each indexed field — keep the result bounded by `len(orgIDs)` and ride the existing `(sectId, account)` / `(deptId, account)` compound indexes.
+- `FindExistingAccounts(ctx, accounts []string) ([]string, error)` — returns the subset of `accounts` that have a matching user document. Single `Distinct` on the indexed `account` field.
+
+Both methods no-op (return `nil, nil`) on empty input, so the round trip is skipped when the request carries only the other dimension.
+
+Two handler helpers (`validateOrgIDs`, `validateAccountsExist` in `room-service/handler.go`) call into the store and return wrapped sentinels:
+
+- `validateOrgIDs` → `errInvalidOrg` (already in `sanitizeError`'s allow-list).
+- `validateAccountsExist` → `errUserNotFound` (already in the allow-list).
+
+The wrap includes the offending id (`fmt.Errorf("org %q: %w", id, errInvalidOrg)`) so logs identify the missing entry; the wire envelope still sanitizes down to the sentinel's `Error()`.
+
+Both helpers are called immediately after the dedup step in `handleAddMembers` (after channel-ref expansion) and `handleCreateRoomChannel`, before `CountNewMembers` and `publishToStream`. Capacity / restricted-channel / bot-rejection checks remain ahead of these (cheaper, no DB call).
+
+## Why the gate lives at the room-service boundary
+
+The room-worker can't return a synchronous error to the client — by the time it sees the event, the room-service has already replied `accepted`. Async-job results (`chat.user.{requesterAccount}.response.{requestID}`) deliver the error, but only when the client opted in by setting `X-Request-ID` and only after the worker reaches the validation point. Pushing the check upstream gives every client an immediate, in-line error envelope and prevents the worker from ever writing the bogus `room_members` row.
+
+## Callers / paths touched
+
+- `room-service/store.go` — two new interface methods, generated mock regenerated.
+- `room-service/store_mongo.go` — `Distinct`-based implementations.
+- `room-service/handler.go` — `validateOrgIDs` + `validateAccountsExist` helpers, called from `handleAddMembers:709` and `handleCreateRoomChannel:296` after `allOrgs`/`allUsers` dedup.
+- `docs/client-api.md` — Add Members and Create Room error-response sections updated to document `"invalid org"` and `"user not found"` synchronous rejections.
+
+## Testing
+
+Unit (`room-service/handler_test.go`):
+- `TestHandler_AddMembers_PhantomOrgRejected` — single phantom org, no publish, `errInvalidOrg`.
+- `TestHandler_AddMembers_PartiallyInvalidOrgRejected` — mixed valid + phantom; the whole request rejects.
+- `TestHandler_AddMembers_NoOrgsSkipsOrgValidation` — gomock fails if `FindExistingOrgIDs` is called for a users-only request.
+- `TestHandler_AddMembers_PhantomUserRejected` — `errUserNotFound`.
+- `TestHandler_AddMembers_NoUsersSkipsUserValidation` — symmetric guard against the unnecessary round trip.
+- 12 existing happy-path tests gain an `expectAllAccountsExist(store)` helper expectation so the validation gate is satisfied.
+
+Integration (`room-service/integration_test.go`):
+- `TestMongoStore_FindExistingOrgIDs_Integration` — sectId + deptId set union, all-phantom, empty-input, dept-only invariant.
+- `TestMongoStore_FindExistingAccounts_Integration` — matching subset, all-phantom, empty-input.
+
+## Out of scope
+
+- Worker-side defense-in-depth (a second validation pass in `room-worker` once the event arrives). The single request-time gate is sufficient; the worker trusts validated canonical events, as it does everywhere else.
+- Org/user existence checks for the `Channels` ref (cross-site bulk-source). Channel expansion already pulls members from `room_members`, which can only carry rows for users that previously existed. The validation gate runs on the resulting `allUsers` set anyway, so a malformed remote source would still surface as `errUserNotFound`.
+- Cross-site federation behavior: an account that exists on a remote site but not locally will reject with `errUserNotFound`. This is intentional under the current single-site `users` collection model; multi-site user replication is a separate problem.
+
+# Part 7 — PR #171 (room-encryption-keys) Follow-up Findings
+
+Two review threads left unresolved on PR #171 after merge. The PR is on `main` now (this branch rebased onto it), so the follow-ups land here.
+
+## Finding 1 — `buildAndFanOutRoomKey` re-fetches the key at `room-worker/handler.go:1789`
+
+**Reviewer comment (`@mliu33`):**
+
+> One optimization is to pass room key down so that buildAndFanout does not need to make db call to fetch again.
+
+**Decision: accept, under a caller-owns-the-fetch contract.**
+
+**Reply to post on the thread:**
+
+> Good catch on the create path — `processCreateRoom` already has the pair in scope from the gate-Get at `handler.go:1188` and discards it before `buildAndFanOutRoomKey` re-fetches at `handler.go:1793`. Refactored `buildAndFanOutRoomKey` to take `*VersionedKeyPair` as a parameter and threaded it through `finishCreateRoom`. Saves one Valkey round trip per channel/DM create.
+>
+> The `processAddMembers` path doesn't have the pair in scope; rather than two ways to invoke (with/without pair) I kept the contract uniform — caller always fetches, function always receives. Net: −1 Valkey Get on create, 0 change on add. The nil check stays inside `buildAndFanOutRoomKey` as a defensive guard so a future caller bug surfaces as a permanent error instead of a panic.
+
+**Why the caller-owns-fetch pattern is safe against concurrent rotation:**
+
+The theoretical race is: create's gate-Get (sees v=0) → concurrent `member.remove` on the same room rotates Valkey to v=1 → create's fan-out runs with the stashed v=0. `member.remove` can only be authorized once the room exists in Mongo, which only happens after `processCreateRoom` commits the room insert, well past the gate-Get. Within a single `processCreateRoom` invocation the pair is stable.
+
+**Code change:**
+
+1. Change `buildAndFanOutRoomKey` signature to take `pair *roomkeystore.VersionedKeyPair`. Keep the nil check and the permanent-absent error path inside as a defensive guard.
+2. Thread `pair` through `finishCreateRoom` (new parameter) and pass through from both `processCreateRoom` call sites.
+3. `processAddMembers`: add a `keyStore.Get` immediately before the fan-out call (unchanged Valkey round-trip count for this path).
+4. Update `TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite` to pass the pair directly and drop the `keyStore.Get` expectation.
+
+## Finding 2 — success counters at `room-worker/handler.go:347, 356, 362`
+
+**Reviewer comment (`@mliu33`):**
+
+> In my opinion, I think there is not much benefit to track success rotate/generate key metrics. It's good to track error count, but not so much for success count as adding metric also costs some cpu time.
+
+**Decision: drop them. Counter `Add` is cheap, but the team's stated convention is errors-only and we follow it.**
+
+**Reply to post on the thread:**
+
+> Agreed — dropped `KeyGenerated` and `KeyRotated` at every emit site (`room-worker/handler.go:347, 356, 362` and `room-service/handler.go:369`) and removed the counter declarations + `init()` registrations from `pkg/roomkeymetrics/metrics.go`. Denominator for error-rate alerting can come from JetStream consumer ack rate or upstream remove/create handler counts. Error counters (`FanoutErrors`, `ValkeyErrors`, `KeyAbsentErrors`) stay.
+
+**Code change:** delete the four `KeyGenerated.Add` / `KeyRotated.Add` call sites and the two counter declarations + their `init()` registration blocks.
diff --git a/history-service/internal/cassrepo/integration_test.go b/history-service/internal/cassrepo/integration_test.go
index 6506984ab..552af6241 100644
--- a/history-service/internal/cassrepo/integration_test.go
+++ b/history-service/internal/cassrepo/integration_test.go
@@ -34,7 +34,6 @@ func setupCassandra(t *testing.T) *gocql.Session {
message_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
@@ -62,7 +61,6 @@ func setupCassandra(t *testing.T) *gocql.Session {
room_id TEXT,
thread_room_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
@@ -95,7 +93,6 @@ func setupCassandra(t *testing.T) *gocql.Session {
created_at TIMESTAMP,
message_id TEXT,
sender FROZEN<"Participant">,
- target_user FROZEN<"Participant">,
msg TEXT,
mentions SET>,
attachments LIST,
diff --git a/history-service/internal/cassrepo/messages_by_id_integration_test.go b/history-service/internal/cassrepo/messages_by_id_integration_test.go
index 6c0a81abe..31f595f02 100644
--- a/history-service/internal/cassrepo/messages_by_id_integration_test.go
+++ b/history-service/internal/cassrepo/messages_by_id_integration_test.go
@@ -55,7 +55,6 @@ func TestRepository_FullRow_AllColumns(t *testing.T) {
threadParent := ts.Add(-1 * time.Hour)
sender := models.Participant{ID: "u1", EngName: "Alice", CompanyName: "Acme", AppID: "app1", AppName: "MyApp", IsBot: false, Account: "alice"}
- target := models.Participant{ID: "u2", Account: "bob"}
mentionUser := models.Participant{ID: "u3", Account: "charlie"}
reactUser := models.Participant{ID: "u4", Account: "dave"}
file := models.File{ID: "f1", Name: "doc.pdf", Type: "application/pdf"}
@@ -69,10 +68,10 @@ func TestRepository_FullRow_AllColumns(t *testing.T) {
pinnedAt := ts.Add(2 * time.Hour)
pinnedBy := models.Participant{ID: "u9", Account: "pinner"}
- insertCQL := `INSERT INTO messages_by_id (room_id, created_at, message_id, sender, target_user, msg, mentions, attachments, file, card, card_action, tshow, thread_parent_id, thread_parent_created_at, quoted_parent_message, visible_to, reactions, deleted, type, sys_msg_data, site_id, edited_at, updated_at, thread_room_id, pinned_at, pinned_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ insertCQL := `INSERT INTO messages_by_id (room_id, created_at, message_id, sender, msg, mentions, attachments, file, card, card_action, tshow, thread_parent_id, thread_parent_created_at, quoted_parent_message, visible_to, reactions, deleted, type, sys_msg_data, site_id, edited_at, updated_at, thread_room_id, pinned_at, pinned_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
insertArgs := []any{
"r-full", ts, "m-full",
- sender, target, "hello world",
+ sender, "hello world",
[]models.Participant{mentionUser},
[][]byte{[]byte("attach1"), []byte("attach2")},
file, card, cardAction,
@@ -102,11 +101,6 @@ func TestRepository_FullRow_AllColumns(t *testing.T) {
assert.Equal(t, "MyApp", msg.Sender.AppName)
assert.False(t, msg.Sender.IsBot)
- // Target user UDT
- require.NotNil(t, msg.TargetUser)
- assert.Equal(t, "u2", msg.TargetUser.ID)
- assert.Equal(t, "bob", msg.TargetUser.Account)
-
// Text
assert.Equal(t, "hello world", msg.Msg)
diff --git a/history-service/internal/cassrepo/messages_by_room.go b/history-service/internal/cassrepo/messages_by_room.go
index 7eae88be5..abc582880 100644
--- a/history-service/internal/cassrepo/messages_by_room.go
+++ b/history-service/internal/cassrepo/messages_by_room.go
@@ -10,7 +10,7 @@ import (
"github.com/hmchangw/chat/history-service/internal/models"
)
-const baseColumns = "room_id, created_at, message_id, thread_room_id, sender, target_user, " +
+const baseColumns = "room_id, created_at, message_id, thread_room_id, sender, " +
"msg, mentions, attachments, file, card, card_action, tshow, tcount, " +
"thread_parent_id, thread_parent_created_at, quoted_parent_message, " +
"visible_to, reactions, deleted, " +
diff --git a/history-service/internal/cassrepo/thread_messages.go b/history-service/internal/cassrepo/thread_messages.go
index 87f4bff67..f386b5a06 100644
--- a/history-service/internal/cassrepo/thread_messages.go
+++ b/history-service/internal/cassrepo/thread_messages.go
@@ -12,7 +12,7 @@ import (
// Subset of columns present in thread_messages_by_room (no tshow, thread_parent_created_at, or pinned_* columns).
const threadMessageColumns = "room_id, thread_room_id, created_at, message_id, thread_parent_id, " +
- "sender, target_user, msg, mentions, attachments, file, card, card_action, " +
+ "sender, msg, mentions, attachments, file, card, card_action, " +
"quoted_parent_message, visible_to, reactions, deleted, " +
"type, sys_msg_data, site_id, edited_at, updated_at"
diff --git a/history-service/internal/cassrepo/thread_messages_integration_test.go b/history-service/internal/cassrepo/thread_messages_integration_test.go
index cf6ab7de9..9dc0f86d3 100644
--- a/history-service/internal/cassrepo/thread_messages_integration_test.go
+++ b/history-service/internal/cassrepo/thread_messages_integration_test.go
@@ -194,7 +194,6 @@ func TestRepository_GetThreadMessages_ColumnScan(t *testing.T) {
bucket := sizer.Of(ts)
sender := models.Participant{ID: "u1", EngName: "Alice", CompanyName: "Acme", AppID: "app1", AppName: "MyApp", IsBot: false, Account: "alice"}
- target := models.Participant{ID: "u2", Account: "bob"}
mentionUser := models.Participant{ID: "u3", Account: "charlie"}
reactUser := models.Participant{ID: "u4", Account: "dave"}
file := models.File{ID: "f1", Name: "doc.pdf", Type: "application/pdf"}
@@ -208,13 +207,13 @@ func TestRepository_GetThreadMessages_ColumnScan(t *testing.T) {
insertCQL := `INSERT INTO thread_messages_by_room (
room_id, bucket, thread_room_id, created_at, message_id, thread_parent_id,
- sender, target_user, msg, mentions, attachments, file, card, card_action,
+ sender, msg, mentions, attachments, file, card, card_action,
quoted_parent_message, visible_to, reactions, deleted,
type, sys_msg_data, site_id, edited_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
insertArgs := []any{
"r-thread-full", bucket, "tr-full", ts, "m-reply-full", "m-thread-parent",
- sender, target, "thread reply body",
+ sender, "thread reply body",
[]models.Participant{mentionUser},
[][]byte{[]byte("attach1"), []byte("attach2")},
file, card, cardAction,
@@ -249,11 +248,6 @@ func TestRepository_GetThreadMessages_ColumnScan(t *testing.T) {
assert.Equal(t, "MyApp", msg.Sender.AppName)
assert.False(t, msg.Sender.IsBot)
- // Target user UDT
- require.NotNil(t, msg.TargetUser)
- assert.Equal(t, "u2", msg.TargetUser.ID)
- assert.Equal(t, "bob", msg.TargetUser.Account)
-
// Text
assert.Equal(t, "thread reply body", msg.Msg)
diff --git a/history-service/internal/service/integration_test.go b/history-service/internal/service/integration_test.go
index abd7b546c..2a7dc8566 100644
--- a/history-service/internal/service/integration_test.go
+++ b/history-service/internal/service/integration_test.go
@@ -43,7 +43,7 @@ func setupCassandra(t *testing.T) *gocql.Session {
// messages_by_room
require.NoError(t, adminSession.Query(cql(`CREATE TABLE IF NOT EXISTS %s.messages_by_room (
room_id TEXT, bucket BIGINT, created_at TIMESTAMP, message_id TEXT, thread_room_id TEXT,
- sender FROZEN<"Participant">, target_user FROZEN<"Participant">, msg TEXT,
+ sender FROZEN<"Participant">, msg TEXT,
mentions SET>, attachments LIST,
file FROZEN<"File">, card FROZEN<"Card">, card_action FROZEN<"CardAction">,
tshow BOOLEAN, tcount INT, thread_parent_id TEXT, thread_parent_created_at TIMESTAMP,
@@ -56,7 +56,7 @@ func setupCassandra(t *testing.T) *gocql.Session {
// messages_by_id
require.NoError(t, adminSession.Query(cql(`CREATE TABLE IF NOT EXISTS %s.messages_by_id (
message_id TEXT, room_id TEXT, thread_room_id TEXT,
- sender FROZEN<"Participant">, target_user FROZEN<"Participant">, msg TEXT,
+ sender FROZEN<"Participant">, msg TEXT,
mentions SET>, attachments LIST,
file FROZEN<"File">, card FROZEN<"Card">, card_action FROZEN<"CardAction">,
tshow BOOLEAN, tcount INT, thread_parent_id TEXT, thread_parent_created_at TIMESTAMP,
@@ -70,7 +70,7 @@ func setupCassandra(t *testing.T) *gocql.Session {
// thread_messages_by_room — needed by TestDeleteMessage_ParentWithReplies_NoCascade
require.NoError(t, adminSession.Query(cql(`CREATE TABLE IF NOT EXISTS %s.thread_messages_by_room (
room_id TEXT, bucket BIGINT, thread_room_id TEXT, created_at TIMESTAMP, message_id TEXT,
- sender FROZEN<"Participant">, target_user FROZEN<"Participant">, msg TEXT,
+ sender FROZEN<"Participant">, msg TEXT,
mentions SET>, attachments LIST,
file FROZEN<"File">, card FROZEN<"Card">, card_action FROZEN<"CardAction">,
thread_parent_id TEXT,
diff --git a/inbox-worker/handler_test.go b/inbox-worker/handler_test.go
index 7781b6ddc..307a0bec2 100644
--- a/inbox-worker/handler_test.go
+++ b/inbox-worker/handler_test.go
@@ -327,7 +327,6 @@ func TestHandleEvent_RoomSync(t *testing.T) {
ID: "room-1",
Name: "general",
Type: model.RoomTypeChannel,
- CreatedBy: "alice",
SiteID: "site-b",
UserCount: 5,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
@@ -383,7 +382,7 @@ func TestHandleEvent_RoomSync_Upsert(t *testing.T) {
// Insert initial room
room1 := model.Room{
ID: "room-1", Name: "old-name", SiteID: "site-b",
- Type: model.RoomTypeChannel, CreatedBy: "alice", UserCount: 2,
+ Type: model.RoomTypeChannel, UserCount: 2,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
@@ -397,7 +396,7 @@ func TestHandleEvent_RoomSync_Upsert(t *testing.T) {
// Update same room with new name
room2 := model.Room{
ID: "room-1", Name: "new-name", SiteID: "site-b",
- Type: model.RoomTypeChannel, CreatedBy: "alice", UserCount: 10,
+ Type: model.RoomTypeChannel, UserCount: 10,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
}
diff --git a/pkg/displayfmt/combine.go b/pkg/displayfmt/combine.go
new file mode 100644
index 000000000..e1b37e620
--- /dev/null
+++ b/pkg/displayfmt/combine.go
@@ -0,0 +1,22 @@
+// Package displayfmt provides display-formatting helpers shared across services.
+package displayfmt
+
+import "strings"
+
+// CombineWithFallback joins first and second with a space; returns the non-empty side or fallback when both are empty.
+func CombineWithFallback(first, second, fallback string) string {
+ first = strings.TrimSpace(first)
+ second = strings.TrimSpace(second)
+ switch {
+ case first == "" && second == "":
+ return fallback
+ case first == "":
+ return second
+ case second == "":
+ return first
+ case first == second:
+ return first
+ default:
+ return first + " " + second
+ }
+}
diff --git a/pkg/displayfmt/combine_test.go b/pkg/displayfmt/combine_test.go
new file mode 100644
index 000000000..70fd4a3b9
--- /dev/null
+++ b/pkg/displayfmt/combine_test.go
@@ -0,0 +1,29 @@
+package displayfmt
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCombineWithFallback(t *testing.T) {
+ tests := []struct {
+ name string
+ first, second, fb string
+ want string
+ }{
+ {"both present", "Eng", "中", "x", "Eng 中"},
+ {"only first", "Eng", "", "x", "Eng"},
+ {"only second", "", "中", "x", "中"},
+ {"both empty", "", "", "fallback", "fallback"},
+ {"equal halves", "Same", "Same", "x", "Same"},
+ {"first whitespace only", " ", "中", "x", "中"},
+ {"second whitespace only", "Eng", " ", "x", "Eng"},
+ {"both whitespace only", " ", " ", "fallback", "fallback"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, CombineWithFallback(tc.first, tc.second, tc.fb))
+ })
+ }
+}
diff --git a/pkg/model/cassandra/message.go b/pkg/model/cassandra/message.go
index 376dd6062..ea7b5b6f4 100644
--- a/pkg/model/cassandra/message.go
+++ b/pkg/model/cassandra/message.go
@@ -78,7 +78,6 @@ type Message struct {
CreatedAt time.Time `json:"createdAt" cql:"created_at"`
MessageID string `json:"messageId" cql:"message_id"`
Sender Participant `json:"sender" cql:"sender"`
- TargetUser *Participant `json:"targetUser,omitempty" cql:"target_user"`
Msg string `json:"msg" cql:"msg"`
Mentions []Participant `json:"mentions,omitempty" cql:"mentions"`
Attachments [][]byte `json:"attachments,omitempty" cql:"attachments"`
diff --git a/pkg/model/cassandra/message_test.go b/pkg/model/cassandra/message_test.go
index 2da2f763c..c72c3c222 100644
--- a/pkg/model/cassandra/message_test.go
+++ b/pkg/model/cassandra/message_test.go
@@ -140,7 +140,6 @@ func TestMessage_JSON(t *testing.T) {
CreatedAt: now,
MessageID: "m1",
Sender: Participant{ID: "u1", Account: "alice", IsBot: false},
- TargetUser: &Participant{ID: "u2", Account: "bob"},
Msg: "hello world",
Mentions: []Participant{{ID: "u3", Account: "charlie"}},
Attachments: [][]byte{[]byte("attach1")},
@@ -172,7 +171,6 @@ func TestMessage_JSON(t *testing.T) {
assert.Equal(t, "r1", got.RoomID)
assert.Equal(t, "m1", got.MessageID)
assert.Equal(t, "alice", got.Sender.Account)
- assert.Equal(t, "bob", got.TargetUser.Account)
assert.Len(t, got.Mentions, 1)
assert.Len(t, got.Attachments, 1)
assert.Equal(t, "doc.pdf", got.File.Name)
@@ -206,7 +204,6 @@ func TestMessage_JSON_Minimal(t *testing.T) {
Msg: "hi",
}
got := roundTrip(t, msg)
- assert.Nil(t, got.TargetUser)
assert.Nil(t, got.File)
assert.Nil(t, got.Card)
assert.Nil(t, got.CardAction)
diff --git a/pkg/model/member.go b/pkg/model/member.go
index 1fa20f49b..fb5084acc 100644
--- a/pkg/model/member.go
+++ b/pkg/model/member.go
@@ -67,8 +67,6 @@ type RemoveMemberRequest struct {
OrgID string `json:"orgId,omitempty" bson:"orgId,omitempty"`
// Set by room-service at acceptance; stable seed for Message.ID + Nats-Msg-Id.
Timestamp int64 `json:"timestamp" bson:"timestamp"`
- // Pre-rotation Valkey version observed by room-service; room-worker's skip-rotation guard fires when Valkey is already ahead.
- BaseKeyVersion int `json:"baseKeyVersion" bson:"baseKeyVersion"`
// Set by room-service after the GetRoom check; carried to room-worker to avoid a redundant Mongo round-trip.
RoomType RoomType `json:"roomType,omitempty" bson:"roomType,omitempty"`
}
diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go
index d06c1e29c..239198290 100644
--- a/pkg/model/model_test.go
+++ b/pkg/model/model_test.go
@@ -29,13 +29,23 @@ func TestUserJSON(t *testing.T) {
roundTrip(t, &u, &model.User{})
}
+func TestUserJSON_WithSectAndDept(t *testing.T) {
+ u := model.User{
+ ID: "u1", Account: "alice", SiteID: "site-a",
+ SectID: "S", SectName: "Sect", SectTCName: "部",
+ DeptID: "D", DeptName: "Dept", DeptTCName: "處",
+ EngName: "Alice", ChineseName: "爱丽丝",
+ }
+ roundTrip(t, &u, &model.User{})
+}
+
func TestRoomJSON(t *testing.T) {
lastMsg := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
lastMention := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
minSeen := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
r := model.Room{
ID: "r1", Name: "general", Type: model.RoomTypeChannel,
- CreatedBy: "u1", SiteID: "site-a", UserCount: 5,
+ SiteID: "site-a", UserCount: 5,
LastMsgAt: &lastMsg,
LastMsgID: "m1",
LastMentionAllAt: &lastMention,
@@ -49,7 +59,7 @@ func TestRoomJSON(t *testing.T) {
func TestRoomJSON_NilTimestampsOmitted(t *testing.T) {
r := model.Room{
ID: "r1", Name: "general", Type: model.RoomTypeChannel,
- CreatedBy: "u1", SiteID: "site-a", UserCount: 1,
+ SiteID: "site-a", UserCount: 1,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
@@ -78,7 +88,7 @@ func TestRoomJSON_NilTimestampsOmitted(t *testing.T) {
func TestRoomJSON_WithDMParticipants(t *testing.T) {
r := model.Room{
ID: "r1", Name: "dm", Type: model.RoomTypeDM,
- CreatedBy: "u1", SiteID: "site-a", UserCount: 2,
+ SiteID: "site-a", UserCount: 2,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UIDs: []string{"u1", "u2"},
@@ -90,7 +100,7 @@ func TestRoomJSON_WithDMParticipants(t *testing.T) {
func TestRoomJSON_NilDMParticipantsOmitted(t *testing.T) {
r := model.Room{
ID: "r1", Name: "general", Type: model.RoomTypeChannel,
- CreatedBy: "u1", SiteID: "site-a", UserCount: 1,
+ SiteID: "site-a", UserCount: 1,
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
@@ -1088,11 +1098,6 @@ func TestRemoveMemberRequestJSON(t *testing.T) {
assert.False(t, hasOrgID, "orgId should be omitted when empty")
})
- t.Run("RemoveMemberRequest with BaseKeyVersion", func(t *testing.T) {
- r := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob",
- Timestamp: 1700000000000, BaseKeyVersion: 3}
- roundTrip(t, &r, &model.RemoveMemberRequest{})
- })
}
func TestMemberRemoveEventJSON(t *testing.T) {
diff --git a/pkg/model/room.go b/pkg/model/room.go
index 0b417c884..bb9a191e1 100644
--- a/pkg/model/room.go
+++ b/pkg/model/room.go
@@ -15,7 +15,6 @@ type Room struct {
ID string `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"`
Type RoomType `json:"type" bson:"type"`
- CreatedBy string `json:"createdBy" bson:"createdBy"`
SiteID string `json:"siteId" bson:"siteId"`
UserCount int `json:"userCount" bson:"userCount"`
AppCount int `json:"appCount" bson:"appCount"`
diff --git a/pkg/model/user.go b/pkg/model/user.go
index 22b2d6b69..f255ef893 100644
--- a/pkg/model/user.go
+++ b/pkg/model/user.go
@@ -6,6 +6,10 @@ type User struct {
SiteID string `json:"siteId" bson:"siteId"`
SectID string `json:"sectId" bson:"sectId"`
SectName string `json:"sectName" bson:"sectName"`
+ SectTCName string `json:"sectTCName" bson:"sectTCName"`
+ DeptID string `json:"deptId" bson:"deptId"`
+ DeptName string `json:"deptName" bson:"deptName"`
+ DeptTCName string `json:"deptTCName" bson:"deptTCName"`
EngName string `json:"engName" bson:"engName"`
ChineseName string `json:"chineseName" bson:"chineseName"`
EmployeeID string `json:"employeeId" bson:"employeeId"`
diff --git a/pkg/pipelines/member.go b/pkg/pipelines/member.go
index ef02eb16a..8e46ceabd 100644
--- a/pkg/pipelines/member.go
+++ b/pkg/pipelines/member.go
@@ -4,7 +4,16 @@
// stages.
package pipelines
-import "go.mongodb.org/mongo-driver/v2/bson"
+import (
+ "errors"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+const botOrPseudoAccountRegex = `(\.bot$|^p_)`
+
+// ErrRoomIDRequired is returned by pipeline builders that require a non-empty roomID.
+var ErrRoomIDRequired = errors.New("roomID required")
// GetNewMembersPipeline returns the common stages for finding the unique,
// non-bot, not-already-subscribed users that an add-members request would
@@ -15,7 +24,8 @@ import "go.mongodb.org/mongo-driver/v2/bson"
// Stages: $match (org/account filter, exclude bots, optionally exclude one
// account), then (when roomID != "") $lookup + $match to filter out
// already-subscribed accounts. Empty roomID returns the $match stage only
-// (used by capacity-check at create time).
+// (used by capacity-check at create time, where the room doesn't exist yet
+// so there are no subscriptions to filter against).
//
// excludeAccount is empty string to disable, or an account that must be
// dropped from the candidate set. Create-room callers pass the requester's
@@ -26,27 +36,7 @@ import "go.mongodb.org/mongo-driver/v2/bson"
// - room-service: bson.M{"$count": "n"} (capacity check)
// - room-worker: bson.M{"$group": {"_id": nil, "accounts": {"$addToSet": "$account"}}}
func GetNewMembersPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) bson.A {
- orFilter := bson.A{}
- if len(orgIDs) > 0 {
- orFilter = append(orFilter, bson.M{"sectId": bson.M{"$in": orgIDs}})
- }
- if len(directAccounts) > 0 {
- orFilter = append(orFilter, bson.M{"account": bson.M{"$in": directAccounts}})
- }
-
- accountFilter := bson.M{
- "$not": bson.Regex{Pattern: `(\.bot$|^p_)`, Options: ""},
- }
- if excludeAccount != "" {
- accountFilter["$ne"] = excludeAccount
- }
- stages := bson.A{
- bson.M{"$match": bson.M{
- "$or": orFilter,
- "account": accountFilter,
- }},
- }
-
+ stages := bson.A{matchCandidates(orgIDs, directAccounts, excludeAccount)}
if roomID != "" {
stages = append(stages,
bson.M{"$lookup": bson.M{
@@ -58,12 +48,75 @@ func GetNewMembersPipeline(orgIDs, directAccounts []string, roomID, excludeAccou
bson.M{"$eq": bson.A{"$u.account", "$$userAccount"}},
}}}},
bson.M{"$limit": 1},
+ // Outer stage only checks emptiness — drop the full sub doc.
+ bson.M{"$project": bson.M{"_id": 1}},
},
"as": "existingSub",
}},
bson.M{"$match": bson.M{"existingSub": bson.M{"$eq": bson.A{}}}},
)
}
-
return stages
}
+
+// matchCandidates: $match users by (sectId|deptId IN orgIDs) OR (account IN directAccounts), bot/excludeAccount filtered.
+func matchCandidates(orgIDs, directAccounts []string, excludeAccount string) bson.M {
+ orFilter := bson.A{}
+ if len(orgIDs) > 0 {
+ orFilter = append(orFilter, bson.M{"sectId": bson.M{"$in": orgIDs}}, bson.M{"deptId": bson.M{"$in": orgIDs}})
+ }
+ if len(directAccounts) > 0 {
+ orFilter = append(orFilter, bson.M{"account": bson.M{"$in": directAccounts}})
+ }
+ accountFilter := bson.M{"$not": bson.Regex{Pattern: botOrPseudoAccountRegex, Options: ""}}
+ if excludeAccount != "" {
+ accountFilter["$ne"] = excludeAccount
+ }
+ return bson.M{"$match": bson.M{"$or": orFilter, "account": accountFilter}}
+}
+
+// GetAddMemberCandidatesPipeline returns per-candidate {account, hasSubscription, hasIndividualRoomMember} for the worker.
+// Returns ErrRoomIDRequired if roomID is empty.
+func GetAddMemberCandidatesPipeline(orgIDs, directAccounts []string, roomID, excludeAccount string) (bson.A, error) {
+ if roomID == "" {
+ return nil, ErrRoomIDRequired
+ }
+ return bson.A{
+ matchCandidates(orgIDs, directAccounts, excludeAccount),
+ bson.M{"$lookup": bson.M{
+ "from": "subscriptions",
+ "let": bson.M{"acct": "$account"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$roomId", roomID}},
+ bson.M{"$eq": bson.A{"$u.account", "$$acct"}},
+ }}}},
+ bson.M{"$limit": 1},
+ // Outer $project only reads $size of _sub — drop everything else.
+ bson.M{"$project": bson.M{"_id": 1}},
+ },
+ "as": "_sub",
+ }},
+ bson.M{"$lookup": bson.M{
+ "from": "room_members",
+ "let": bson.M{"uid": "$_id"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$rid", roomID}},
+ bson.M{"$eq": bson.A{"$member.type", "individual"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$uid"}},
+ }}}},
+ bson.M{"$limit": 1},
+ // Same rationale as the _sub lookup above.
+ bson.M{"$project": bson.M{"_id": 1}},
+ },
+ "as": "_irm",
+ }},
+ bson.M{"$project": bson.M{
+ "_id": 0,
+ "account": "$account",
+ "hasSubscription": bson.M{"$gt": bson.A{bson.M{"$size": "$_sub"}, 0}},
+ "hasIndividualRoomMember": bson.M{"$gt": bson.A{bson.M{"$size": "$_irm"}, 0}},
+ }},
+ }, nil
+}
diff --git a/pkg/pipelines/member_test.go b/pkg/pipelines/member_test.go
index 966448fc9..d3b449e14 100644
--- a/pkg/pipelines/member_test.go
+++ b/pkg/pipelines/member_test.go
@@ -20,9 +20,11 @@ func TestGetNewMembersPipeline(t *testing.T) {
match := stage0["$match"].(bson.M)
orFilter := match["$or"].(bson.A)
- assert.Len(t, orFilter, 2)
+ // sectId + deptId for the one orgID group, plus account = 3 clauses.
+ assert.Len(t, orFilter, 3)
assert.NotNil(t, orFilter[0])
assert.NotNil(t, orFilter[1])
+ assert.NotNil(t, orFilter[2])
})
t.Run("bot exclusion via $not regex", func(t *testing.T) {
@@ -40,9 +42,12 @@ func TestGetNewMembersPipeline(t *testing.T) {
match := stage0["$match"].(bson.M)
orFilter := match["$or"].(bson.A)
- assert.Len(t, orFilter, 1)
+ // sectId + deptId clauses for the org group.
+ assert.Len(t, orFilter, 2)
sectIdFilter := orFilter[0].(bson.M)
assert.Contains(t, sectIdFilter, "sectId")
+ deptIdFilter := orFilter[1].(bson.M)
+ assert.Contains(t, deptIdFilter, "deptId")
})
t.Run("$or filter contains directAccounts when provided", func(t *testing.T) {
@@ -110,3 +115,56 @@ func TestGetNewMembersPipelineWithRoomIDStillHasLookup(t *testing.T) {
}
assert.True(t, hasLookup, "non-empty roomID must keep the subscriptions $lookup")
}
+
+func TestGetAddMemberCandidatesPipeline(t *testing.T) {
+ t.Run("stage count is 4", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline([]string{"org1"}, []string{"alice"}, "room1", "")
+ require.NoError(t, err)
+ assert.Len(t, got, 4)
+ })
+
+ t.Run("$match shape with orgIDs only — 2 OR clauses: sectId, deptId", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline([]string{"org1"}, nil, "room1", "")
+ require.NoError(t, err)
+ stage0 := got[0].(bson.M)
+ match := stage0["$match"].(bson.M)
+ orFilter := match["$or"].(bson.A)
+ assert.Len(t, orFilter, 2, "sectId + deptId clauses for orgIDs only")
+ assert.Contains(t, orFilter[0].(bson.M), "sectId")
+ assert.Contains(t, orFilter[1].(bson.M), "deptId")
+ })
+
+ t.Run("$match shape with directAccounts only — 1 OR clause for account", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline(nil, []string{"alice"}, "room1", "")
+ require.NoError(t, err)
+ stage0 := got[0].(bson.M)
+ match := stage0["$match"].(bson.M)
+ orFilter := match["$or"].(bson.A)
+ assert.Len(t, orFilter, 1)
+ assert.Contains(t, orFilter[0].(bson.M), "account")
+ })
+
+ t.Run("$match shape with both — 3 elements in $or: sectId, deptId, account", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline([]string{"org1"}, []string{"alice"}, "room1", "")
+ require.NoError(t, err)
+ stage0 := got[0].(bson.M)
+ match := stage0["$match"].(bson.M)
+ orFilter := match["$or"].(bson.A)
+ assert.Len(t, orFilter, 3, "sectId + deptId + account")
+ })
+
+ t.Run("excludeAccount adds $ne to the account filter", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline([]string{"org1"}, []string{"alice"}, "room1", "exclude-me")
+ require.NoError(t, err)
+ stage0 := got[0].(bson.M)
+ match := stage0["$match"].(bson.M)
+ accountFilter := match["account"].(bson.M)
+ assert.Equal(t, "exclude-me", accountFilter["$ne"])
+ })
+
+ t.Run("error on empty roomID", func(t *testing.T) {
+ got, err := GetAddMemberCandidatesPipeline([]string{"org1"}, nil, "", "")
+ assert.Nil(t, got)
+ assert.ErrorIs(t, err, ErrRoomIDRequired)
+ })
+}
diff --git a/pkg/roomkeymetrics/metrics.go b/pkg/roomkeymetrics/metrics.go
index 79527b19d..518aa6a56 100644
--- a/pkg/roomkeymetrics/metrics.go
+++ b/pkg/roomkeymetrics/metrics.go
@@ -11,10 +11,6 @@ import (
var (
// FanoutErrors counts the number of failed RoomKeyEvent sends to a single account.
FanoutErrors metric.Int64Counter
- // KeyGenerated counts the number of new keys generated for rooms.
- KeyGenerated metric.Int64Counter
- // KeyRotated counts the number of successful key rotations.
- KeyRotated metric.Int64Counter
// ValkeyErrors counts Valkey operation failures, tagged by operation name.
ValkeyErrors metric.Int64Counter
// KeyAbsentErrors fires when Valkey is healthy but no current key exists for a room
@@ -36,22 +32,6 @@ func init() {
FanoutErrors, _ = noop.NewMeterProvider().Meter("room-key").Int64Counter("room_key_fanout_errors_total")
}
- KeyGenerated, err = m.Int64Counter(
- "room_key_generated_total",
- metric.WithDescription("Number of new room encryption keys generated"),
- )
- if err != nil {
- KeyGenerated, _ = noop.NewMeterProvider().Meter("room-key").Int64Counter("room_key_generated_total")
- }
-
- KeyRotated, err = m.Int64Counter(
- "room_key_rotated_total",
- metric.WithDescription("Number of successful room key rotations"),
- )
- if err != nil {
- KeyRotated, _ = noop.NewMeterProvider().Meter("room-key").Int64Counter("room_key_rotated_total")
- }
-
ValkeyErrors, err = m.Int64Counter(
"room_key_valkey_errors_total",
metric.WithDescription("Number of Valkey operation failures"),
diff --git a/pkg/testutil/testimages/testimages.go b/pkg/testutil/testimages/testimages.go
index 68ca5e68b..70b5d0b5f 100644
--- a/pkg/testutil/testimages/testimages.go
+++ b/pkg/testutil/testimages/testimages.go
@@ -20,10 +20,19 @@ const (
Cassandra = "cassandra:4.1.3"
// Mongo is the image for every Mongo-backed integration test.
- // Pinned to 4.4.15 to match production; catches operator-allow-list
- // regressions like $in in partialFilterExpression that newer Mongo
- // silently accepts.
- Mongo = "mongo:4.4.15"
+ // Tracks the deploy stack (docker-local/compose.deps.yaml) so tests
+ // exercise the same major version we ship.
+ //
+ // Previously pinned to 4.4.15 to catch operator-allow-list regressions
+ // that newer Mongo silently accepts (e.g. $in inside
+ // partialFilterExpression). That guard is retired here because prod
+ // has moved to Mongo 8; if a regression of that shape recurs, the
+ // replacement guard is whatever lint/validation the deploy stack
+ // enforces — not an integration-test version pin.
+ //
+ // Patch-pinned so testcontainers can't drift across patch releases
+ // between CI runs. Bump in lockstep with docker-local/compose.deps.yaml.
+ Mongo = "mongo:8.2.9"
// NATS is the image for every NATS-backed integration test
// (core NATS + JetStream + WebSocket).
@@ -37,8 +46,10 @@ const (
Elasticsearch = "elasticsearch:8.17.0"
// Valkey is the Redis-compatible cache used by room-service and
- // pkg/roomkeystore.
- Valkey = "valkey/valkey:8"
+ // pkg/roomkeystore. Tracks docker-local/compose.deps.yaml.
+ //
+ // Patch-pinned (same rationale as Mongo above).
+ Valkey = "valkey/valkey:8.1.7-alpine"
// MinIO is the image for every MinIO-backed integration test.
MinIO = "minio/minio:RELEASE.2025-01-20T14-49-07Z"
diff --git a/room-service/handler.go b/room-service/handler.go
index 50771761d..8bd449d13 100644
--- a/room-service/handler.go
+++ b/room-service/handler.go
@@ -310,6 +310,16 @@ func (h *Handler) handleCreateRoomChannel(ctx context.Context, req *model.Create
return nil, errEmptyCreateRequest
}
+ // Reject phantom orgs before sizing/publishing, same reason as
+ // handleAddMembers: the worker writes room_members + sys-msg without
+ // rechecking org validity.
+ if err := h.validateOrgIDs(ctx, allOrgs); err != nil {
+ return nil, err
+ }
+ if err := h.validateAccountsExist(ctx, allUsers); err != nil {
+ return nil, err
+ }
+
// Pass requesterAccount as excludeAccount: the requester was stripped from
// allUsers but can still be re-added by org expansion (when their account
// is in any of the resolved orgs). Excluding them from the count lets us
@@ -359,7 +369,6 @@ func (h *Handler) publishCreateRoom(ctx context.Context, req *model.CreateRoomRe
roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Set")))
return nil, fmt.Errorf("store room key: %w", err)
}
- roomkeymetrics.KeyGenerated.Add(ctx, 1)
}
payload, err := json.Marshal(req)
@@ -536,18 +545,6 @@ func (h *Handler) handleRemoveMember(ctx context.Context, subj string, data []by
// Stable seed for room-worker's deterministic system-message IDs across JetStream redeliveries.
req.Timestamp = time.Now().UTC().UnixMilli()
- // Stamp current Valkey version so room-worker's skip-rotation guard can detect already-rotated redeliveries.
- if h.keyStore != nil {
- current, err := h.keyStore.Get(ctx, req.RoomID)
- if err != nil {
- roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Get")))
- return nil, fmt.Errorf("get current room key: %w", err)
- }
- if current != nil {
- req.BaseKeyVersion = current.Version
- }
- }
-
// Publish to ROOMS stream for room-worker processing.
data, err = json.Marshal(req)
if err != nil {
@@ -713,6 +710,19 @@ func (h *Handler) handleAddMembers(ctx context.Context, subj string, data []byte
allOrgs := dedup(append(req.Orgs, channelOrgIDs...))
allUsers := dedup(append(req.Users, channelAccounts...))
+ // 6a. Reject phantom orgs up front. Without this, room-worker writes a
+ // room_members row for the bogus orgId and fans out a "members added"
+ // sys-msg even though no user matches the org.
+ if err := h.validateOrgIDs(ctx, allOrgs); err != nil {
+ return nil, err
+ }
+ // 6b. Reject phantom users symmetrically — a typo'd account would be
+ // silently dropped by the candidates pipeline and the async job would
+ // still report success.
+ if err := h.validateAccountsExist(ctx, allUsers); err != nil {
+ return nil, err
+ }
+
// 7. Count net-new members (count-only — actual list materialized in room-worker)
newCount, err := h.store.CountNewMembers(ctx, allOrgs, allUsers, roomID, "")
if err != nil {
@@ -746,6 +756,60 @@ func (h *Handler) handleAddMembers(ctx context.Context, subj string, data []byte
return json.Marshal(map[string]string{"status": "accepted"})
}
+// validateAccountsExist wraps errUserNotFound with the first phantom account
+// (via fmt.Errorf("user %q: %w", …)) when any account has no matching user
+// document; errors.Is(err, errUserNotFound) holds. Without this gate a typo'd
+// account is silently dropped and the async job reports success.
+func (h *Handler) validateAccountsExist(ctx context.Context, accounts []string) error {
+ if len(accounts) == 0 {
+ return nil
+ }
+ existing, err := h.store.FindExistingAccounts(ctx, accounts)
+ if err != nil {
+ return fmt.Errorf("validate accounts: %w", err)
+ }
+ if len(existing) == len(accounts) {
+ return nil
+ }
+ have := make(map[string]struct{}, len(existing))
+ for _, a := range existing {
+ have[a] = struct{}{}
+ }
+ for _, a := range accounts {
+ if _, ok := have[a]; !ok {
+ return fmt.Errorf("user %q: %w", a, errUserNotFound)
+ }
+ }
+ return nil
+}
+
+// validateOrgIDs wraps errInvalidOrg with the first phantom orgID (via
+// fmt.Errorf("org %q: %w", …)) when any orgID has zero backing users
+// (no user with sectId==orgID or deptId==orgID); errors.Is(err, errInvalidOrg)
+// holds. No-op when orgIDs is empty.
+func (h *Handler) validateOrgIDs(ctx context.Context, orgIDs []string) error {
+ if len(orgIDs) == 0 {
+ return nil
+ }
+ existing, err := h.store.FindExistingOrgIDs(ctx, orgIDs)
+ if err != nil {
+ return fmt.Errorf("validate org ids: %w", err)
+ }
+ if len(existing) == len(orgIDs) {
+ return nil
+ }
+ have := make(map[string]struct{}, len(existing))
+ for _, id := range existing {
+ have[id] = struct{}{}
+ }
+ for _, id := range orgIDs {
+ if _, ok := have[id]; !ok {
+ return fmt.Errorf("org %q: %w", id, errInvalidOrg)
+ }
+ }
+ return nil
+}
+
func (h *Handler) expandChannelRefs(ctx context.Context, requester string, refs []model.ChannelRef) (orgIDs, accounts []string, err error) {
// maxRoomSize+1 is enough to distinguish "fits" from "exceeds the cap" without
// ever materializing an unbounded result set in memory.
diff --git a/room-service/handler_test.go b/room-service/handler_test.go
index 870926101..695ec4adb 100644
--- a/room-service/handler_test.go
+++ b/room-service/handler_test.go
@@ -24,6 +24,15 @@ import (
"github.com/hmchangw/chat/pkg/subject"
)
+// expectAllAccountsExist registers a FindExistingAccounts expectation that
+// echoes its input back — i.e. "every account being asked about exists".
+// Used by every add-member / create-channel happy-path test that doesn't
+// specifically test the missing-user branch.
+func expectAllAccountsExist(store *MockRoomStore) *gomock.Call {
+ return store.EXPECT().FindExistingAccounts(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(_ context.Context, accs []string) ([]string, error) { return accs, nil })
+}
+
func TestHandler_UpdateRole_Success(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
@@ -922,6 +931,7 @@ func TestHandler_AddMembers_CapacityExceeded(t *testing.T) {
store.EXPECT().
GetRoom(gomock.Any(), "r1").
Return(&model.Room{ID: "r1", Name: "general", Type: model.RoomTypeChannel, UserCount: 8}, nil)
+ expectAllAccountsExist(store)
store.EXPECT().
CountNewMembers(gomock.Any(), gomock.Any(), []string{"u1", "u2", "u3", "u4", "u5"}, "r1", gomock.Any()).
Return(5, nil)
@@ -952,6 +962,7 @@ func TestHandler_AddMembers_RestrictedOwnerAllowed(t *testing.T) {
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Type: model.RoomTypeChannel, Restricted: true, UserCount: 1,
}, nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1", gomock.Any()).
Return(1, nil)
@@ -979,6 +990,7 @@ func TestHandler_AddMembers_EmptyAfterResolve(t *testing.T) {
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Type: model.RoomTypeChannel, UserCount: 5,
}, nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1", gomock.Any()).
Return(0, nil)
@@ -1039,6 +1051,7 @@ func TestHandler_AddMembers_SilentlyFiltersBotsFromChannelRefs(t *testing.T) {
}, nil)
// CountNewMembers must be called with bob only — the bot is filtered before counting.
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(),
[]string{"bob"}, "r1", gomock.Any()).Return(1, nil)
@@ -1065,6 +1078,209 @@ func TestHandler_AddMembers_SilentlyFiltersBotsFromChannelRefs(t *testing.T) {
assert.Contains(t, published.Users, "bob")
}
+// expectAliceOwnerOfR1 wires up the AddMembers preflight: alice is owner of
+// channel r1 with 1 existing member. Reused by every AddMembers phantom-validation case.
+func expectAliceOwnerOfR1(store *MockRoomStore) {
+ store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1").Return(&model.Subscription{
+ User: model.SubscriptionUser{ID: "u1", Account: "alice"},
+ Roles: []model.Role{model.RoleOwner},
+ }, nil)
+ store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
+ ID: "r1", Type: model.RoomTypeChannel, UserCount: 1,
+ }, nil)
+}
+
+// errStoreFailure is a sentinel used in store-error branch tests. Distinct
+// from the validators' errInvalidOrg/errUserNotFound so the test can verify
+// that the store error wraps cleanly without being masked by the sentinel.
+var errStoreFailure = errors.New("store boom")
+
+// TestHandler_AddMembers_PhantomValidation covers the gate that converts the
+// candidates pipeline's silent-drop into a synchronous reject. Cases:
+// - happy paths (no-orgs / no-users / no-channels) skip the matching validator
+// - phantom org or user (incl. partially-invalid batch) rejects with the
+// sentinel and no publish to the canonical stream
+// - store error from either validator propagates wrapped (validates the
+// coverage gap on the FindExistingOrgIDs / FindExistingAccounts error branch)
+func TestHandler_AddMembers_PhantomValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ req model.AddMembersRequest
+ setupMocks func(store *MockRoomStore)
+ wantErr bool
+ wantErrSentinel error
+ wantPublish bool
+ }{
+ {
+ name: "phantom org alone rejected",
+ req: model.AddMembersRequest{Orgs: []string{"org-nope"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), []string{"org-nope"}).Return(nil, nil)
+ },
+ wantErr: true, wantErrSentinel: errInvalidOrg, wantPublish: false,
+ },
+ {
+ name: "partially invalid org rejected",
+ req: model.AddMembersRequest{Orgs: []string{"good-org", "bad-org"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), gomock.InAnyOrder([]string{"good-org", "bad-org"})).
+ Return([]string{"good-org"}, nil)
+ },
+ wantErr: true, wantErrSentinel: errInvalidOrg, wantPublish: false,
+ },
+ {
+ name: "no orgs skips org validation",
+ req: model.AddMembersRequest{Users: []string{"bob"}},
+ setupMocks: func(store *MockRoomStore) {
+ // No FindExistingOrgIDs expectation: gomock fails if called.
+ store.EXPECT().FindExistingAccounts(gomock.Any(), []string{"bob"}).Return([]string{"bob"}, nil)
+ store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), []string{"bob"}, "r1", gomock.Any()).Return(1, nil)
+ },
+ wantErr: false, wantPublish: true,
+ },
+ {
+ name: "phantom user alone rejected",
+ req: model.AddMembersRequest{Users: []string{"bob", "ghost"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingAccounts(gomock.Any(), gomock.InAnyOrder([]string{"bob", "ghost"})).
+ Return([]string{"bob"}, nil)
+ },
+ wantErr: true, wantErrSentinel: errUserNotFound, wantPublish: false,
+ },
+ {
+ name: "no users skips user validation",
+ req: model.AddMembersRequest{Orgs: []string{"good-org"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), []string{"good-org"}).Return([]string{"good-org"}, nil)
+ // No FindExistingAccounts expectation: gomock fails if called.
+ store.EXPECT().CountNewMembers(gomock.Any(), []string{"good-org"}, gomock.Any(), "r1", gomock.Any()).Return(1, nil)
+ },
+ wantErr: false, wantPublish: true,
+ },
+ {
+ name: "FindExistingOrgIDs store error propagates",
+ req: model.AddMembersRequest{Orgs: []string{"org-a"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), []string{"org-a"}).Return(nil, errStoreFailure)
+ },
+ wantErr: true, wantErrSentinel: errStoreFailure, wantPublish: false,
+ },
+ {
+ name: "FindExistingAccounts store error propagates",
+ req: model.AddMembersRequest{Users: []string{"bob"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingAccounts(gomock.Any(), []string{"bob"}).Return(nil, errStoreFailure)
+ },
+ wantErr: true, wantErrSentinel: errStoreFailure, wantPublish: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockRoomStore(ctrl)
+ expectAliceOwnerOfR1(store)
+ tc.setupMocks(store)
+
+ publishCalled := false
+ h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000,
+ publishToStream: func(_ context.Context, _ string, _ []byte) error {
+ publishCalled = true
+ return nil
+ },
+ }
+ body, _ := json.Marshal(tc.req)
+ _, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), body)
+ if tc.wantErr {
+ require.Error(t, err)
+ if tc.wantErrSentinel != nil {
+ assert.True(t, errors.Is(err, tc.wantErrSentinel), "want %v, got %v", tc.wantErrSentinel, err)
+ }
+ } else {
+ require.NoError(t, err)
+ }
+ assert.Equal(t, tc.wantPublish, publishCalled, "publish behavior mismatch")
+ })
+ }
+}
+
+// TestHandler_CreateRoomChannel_PhantomValidation is the parallel coverage for
+// handleCreateRoomChannel — the same validateOrgIDs / validateAccountsExist
+// gates are wired in but until now only the AddMembers path was exercised. A
+// regression that dropped the gate on create-channel would have shipped silently.
+func TestHandler_CreateRoomChannel_PhantomValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ req model.CreateRoomRequest
+ setupMocks func(store *MockRoomStore)
+ wantErr bool
+ wantErrSentinel error
+ wantPublish bool
+ }{
+ {
+ name: "phantom org rejected",
+ req: model.CreateRoomRequest{Name: "general", Orgs: []string{"org-nope"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), []string{"org-nope"}).Return(nil, nil)
+ },
+ wantErr: true, wantErrSentinel: errInvalidOrg, wantPublish: false,
+ },
+ {
+ name: "phantom user rejected",
+ req: model.CreateRoomRequest{Name: "general", Users: []string{"bob", "ghost"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingAccounts(gomock.Any(), gomock.InAnyOrder([]string{"bob", "ghost"})).
+ Return([]string{"bob"}, nil)
+ },
+ wantErr: true, wantErrSentinel: errUserNotFound, wantPublish: false,
+ },
+ {
+ name: "FindExistingOrgIDs store error propagates",
+ req: model.CreateRoomRequest{Name: "general", Orgs: []string{"org-a"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingOrgIDs(gomock.Any(), []string{"org-a"}).Return(nil, errStoreFailure)
+ },
+ wantErr: true, wantErrSentinel: errStoreFailure, wantPublish: false,
+ },
+ {
+ name: "FindExistingAccounts store error propagates",
+ req: model.CreateRoomRequest{Name: "general", Users: []string{"bob"}},
+ setupMocks: func(store *MockRoomStore) {
+ store.EXPECT().FindExistingAccounts(gomock.Any(), []string{"bob"}).Return(nil, errStoreFailure)
+ },
+ wantErr: true, wantErrSentinel: errStoreFailure, wantPublish: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockRoomStore(ctrl)
+ store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ tc.setupMocks(store)
+
+ publishCalled := false
+ h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000,
+ publishToStream: func(_ context.Context, _ string, _ []byte) error {
+ publishCalled = true
+ return nil
+ },
+ }
+ body, _ := json.Marshal(tc.req)
+ _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body)
+ if tc.wantErr {
+ require.Error(t, err)
+ if tc.wantErrSentinel != nil {
+ assert.True(t, errors.Is(err, tc.wantErrSentinel), "want %v, got %v", tc.wantErrSentinel, err)
+ }
+ } else {
+ require.NoError(t, err)
+ }
+ assert.Equal(t, tc.wantPublish, publishCalled, "publish behavior mismatch")
+ })
+ }
+}
+
func TestHandler_AddMembers_ChannelExpansion(t *testing.T) {
t.Run("same-site individuals only", func(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -2232,6 +2448,7 @@ func TestHandleCreateRoom_Channel_HappyPath(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(2, nil)
var publishedData []byte
@@ -2295,6 +2512,7 @@ func TestHandleCreateRoom_Channel_NameAtBoundary(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(2, nil)
h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000,
publishToStream: func(_ context.Context, _ string, _ []byte) error { return nil },
@@ -2321,6 +2539,7 @@ func TestHandleCreateRoom_Channel_ExceedsCapacity(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(11, nil)
h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10}
@@ -2336,6 +2555,7 @@ func TestHandleCreateRoom_Channel_ChannelRefsExpandToCreatorOnly(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
// expandChannelRefs would resolve a same-site channel-ref where the only
// member is alice — after stripping the requester, allUsers/allOrgs are
// empty for the count call, returning 0.
@@ -2354,6 +2574,7 @@ func TestHandleCreateRoom_Channel_RejectsWhenCreatorWouldOverflow(t *testing.T)
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(10, nil)
h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10}
@@ -2369,6 +2590,7 @@ func TestHandleCreateRoom_Channel_AcceptsAtCreatorInclusiveCap(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockRoomStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(9, nil)
h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10,
publishToStream: func(_ context.Context, _ string, _ []byte) error { return nil }}
@@ -2994,6 +3216,7 @@ func TestHandler_CreateRoom_WritesKeyBeforePublish(t *testing.T) {
keyStore := NewMockRoomKeyStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", "alice").
Return(1, nil)
@@ -3033,6 +3256,7 @@ func TestHandler_CreateRoom_AbortsOnKeyStoreSetError(t *testing.T) {
keyStore := NewMockRoomKeyStore(ctrl)
store.EXPECT().GetUser(gomock.Any(), "alice").Return(aliceUser(), nil)
+ expectAllAccountsExist(store)
store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", "alice").
Return(1, nil)
keyStore.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any()).
@@ -3053,41 +3277,6 @@ func TestHandler_CreateRoom_AbortsOnKeyStoreSetError(t *testing.T) {
assert.Contains(t, err.Error(), "store room key")
}
-func TestHandler_RemoveMember_StampsBaseKeyVersion(t *testing.T) {
- ctrl := gomock.NewController(t)
- store := NewMockRoomStore(ctrl)
- keyStore := NewMockRoomKeyStore(ctrl)
-
- store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().GetSubscriptionWithMembership(gomock.Any(), "r1", "bob").Return(
- &SubscriptionWithMembership{
- Subscription: &model.Subscription{User: model.SubscriptionUser{Account: "bob"}, RoomID: "r1", Roles: []model.Role{model.RoleMember}},
- HasIndividualMembership: true,
- }, nil)
- store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1").Return(
- &model.Subscription{User: model.SubscriptionUser{Account: "alice"}, RoomID: "r1",
- Roles: []model.Role{model.RoleOwner, model.RoleMember}}, nil)
- store.EXPECT().CountMembersAndOwners(gomock.Any(), "r1").Return(
- &RoomCounts{MemberCount: 5, OwnerCount: 2}, nil)
- // Read current version; room-worker uses this as the skip-rotation baseline.
- keyStore.EXPECT().Get(gomock.Any(), "r1").Return(&roomkeystore.VersionedKeyPair{Version: 4}, nil)
-
- var captured model.RemoveMemberRequest
- publish := func(_ context.Context, _ string, data []byte) error {
- require.NoError(t, json.Unmarshal(data, &captured))
- return nil
- }
-
- h := &Handler{store: store, keyStore: keyStore, siteID: "site-a", maxRoomSize: 1000, publishToStream: publish}
-
- req := model.RemoveMemberRequest{Account: "bob"}
- data, _ := json.Marshal(req)
- _, err := h.handleRemoveMember(ctxWithReqID(),
- "chat.user.alice.request.room.r1.site-a.member.remove", data)
- require.NoError(t, err)
- assert.Equal(t, 4, captured.BaseKeyVersion, "BaseKeyVersion must be stamped from the current Valkey version")
-}
-
func TestHandler_EnsureRoomKey_KeyExists(t *testing.T) {
ctrl := gomock.NewController(t)
keyStore := NewMockRoomKeyStore(ctrl)
diff --git a/room-service/integration_test.go b/room-service/integration_test.go
index 732ec4c10..a0e2c29da 100644
--- a/room-service/integration_test.go
+++ b/room-service/integration_test.go
@@ -97,21 +97,21 @@ func TestMongoStore_Integration(t *testing.T) {
ctx := context.Background()
// Test CreateRoom and GetRoom
- room := model.Room{ID: "r1", Name: "general", Type: model.RoomTypeChannel, SiteID: "site-a", CreatedBy: "u1", UserCount: 1}
- require.NoError(t, store.CreateRoom(ctx, &room))
+ room := model.Room{ID: "r1", Name: "general", Type: model.RoomTypeChannel, SiteID: "site-a", UserCount: 1}
+ mustInsertRoom(t, db, &room)
got, err := store.GetRoom(ctx, "r1")
require.NoError(t, err)
assert.Equal(t, "general", got.Name)
// Test ListRooms
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "r2", Name: "random", Type: model.RoomTypeChannel}))
+ mustInsertRoom(t, db, &model.Room{ID: "r2", Name: "random", Type: model.RoomTypeChannel})
rooms, err := store.ListRooms(ctx)
require.NoError(t, err)
assert.Len(t, rooms, 2)
// Test CreateSubscription and GetSubscription
sub := model.Subscription{ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: "alice"}, RoomID: "r1", Roles: []model.Role{model.RoleOwner}}
- require.NoError(t, store.CreateSubscription(ctx, &sub))
+ mustInsertSub(t, db, &sub)
gotSub, err := store.GetSubscription(ctx, "alice", "r1")
require.NoError(t, err)
// Bound the slice access explicitly with require.Len before indexing —
@@ -138,7 +138,7 @@ func TestMongoStore_GetSubscriptionWithMembership_Integration(t *testing.T) {
RoomID: "r1", SiteID: "site-a", Roles: []model.Role{model.RoleOwner},
JoinedAt: time.Now().UTC(),
}
- require.NoError(t, store.CreateSubscription(ctx, sub))
+ mustInsertSub(t, db, sub)
t.Run("no individual or org membership", func(t *testing.T) {
result, err := store.GetSubscriptionWithMembership(ctx, "r1", "alice")
@@ -183,6 +183,44 @@ func TestMongoStore_GetSubscriptionWithMembership_Integration(t *testing.T) {
})
}
+// TestMongoStore_GetSubscriptionWithMembership_DeptOnlyMatch_Integration pins
+// the dept-aware org-membership lookup: a user added via Orgs:["X"] whose
+// deptId is "X" (with no sectId match) must still report HasOrgMembership=true
+// so the remove flow preserves their subscription. Checking only sectId would
+// miss this case and the dual-membership branch wouldn't fire — the sub would
+// be deleted even though the user remains org-attached via the dept.
+func TestMongoStore_GetSubscriptionWithMembership_DeptOnlyMatch_Integration(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ ctx := context.Background()
+
+ const roomID = "r-dept-only"
+ const account = "alice"
+
+ // Alice has deptId="X" and NO sectId. The org row in room_members is keyed
+ // by member.id="X" — the dept-blind sectId-only lookup would miss it.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: account},
+ RoomID: roomID, SiteID: "site-a", Roles: []model.Role{model.RoleMember},
+ JoinedAt: time.Now().UTC(),
+ })
+ _, err := db.Collection("users").InsertOne(ctx, model.User{
+ ID: "u1", Account: account, SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: "rm-org", RoomID: roomID, Ts: time.Now().UTC(),
+ Member: model.RoomMemberEntry{ID: "X", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ result, err := store.GetSubscriptionWithMembership(ctx, roomID, account)
+ require.NoError(t, err)
+ assert.True(t, result.HasOrgMembership,
+ "deptId match must count as org membership — without it, the dual-membership branch wouldn't fire and the sub would be deleted on remove")
+}
+
func TestMongoStore_CountMembersAndOwners_Integration(t *testing.T) {
db := setupMongo(t)
store := NewMongoStore(db)
@@ -341,9 +379,9 @@ func TestMongoStore_ListRoomMembers_Integration(t *testing.T) {
_, err := db.Collection("room_members").InsertOne(ctx, rm)
require.NoError(t, err)
}
- insertSub := func(t *testing.T, store *MongoStore, sub model.Subscription) {
+ insertSub := func(t *testing.T, db *mongo.Database, sub model.Subscription) {
t.Helper()
- require.NoError(t, store.CreateSubscription(ctx, &sub))
+ mustInsertSub(t, db, &sub)
}
ptr := func(i int) *int { return &i }
@@ -372,11 +410,11 @@ func TestMongoStore_ListRoomMembers_Integration(t *testing.T) {
db := setupMongo(t)
store := NewMongoStore(db)
base := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-a", User: model.SubscriptionUser{ID: "u-alice", Account: "alice"},
RoomID: "r1", SiteID: "site-a", JoinedAt: base.Add(10 * time.Second),
})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-b", User: model.SubscriptionUser{ID: "u-bob", Account: "bob"},
RoomID: "r1", SiteID: "site-a", JoinedAt: base.Add(20 * time.Second),
})
@@ -465,7 +503,7 @@ func TestMongoStore_ListRoomMembers_Integration(t *testing.T) {
insertRM(t, db, model.RoomMember{ID: "rm-org-2", RoomID: "r1", Ts: base.Add(20 * time.Second),
Member: model.RoomMemberEntry{ID: "org-2", Type: model.RoomMemberOrg}})
// Also seed a subscription — must NOT appear in the result since room_members is non-empty.
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-ghost", User: model.SubscriptionUser{ID: "u-ghost", Account: "ghost"},
RoomID: "r1", SiteID: "site-a", JoinedAt: base,
})
@@ -513,9 +551,9 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
_, err := db.Collection("users").InsertOne(ctx, u)
require.NoError(t, err)
}
- insertSub := func(t *testing.T, store *MongoStore, sub model.Subscription) {
+ insertSub := func(t *testing.T, db *mongo.Database, sub model.Subscription) {
t.Helper()
- require.NoError(t, store.CreateSubscription(ctx, &sub))
+ mustInsertSub(t, db, &sub)
}
ptr := func(i int) *int { return &i }
@@ -528,7 +566,7 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
ID: "u-alice", Account: "alice", SiteID: "site-a",
EngName: "Alice Wang", ChineseName: "愛麗絲",
})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-alice", User: model.SubscriptionUser{ID: "u-alice", Account: "alice"},
RoomID: "r1", SiteID: "site-a",
Roles: []model.Role{model.RoleOwner}, JoinedAt: base,
@@ -555,7 +593,7 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
base := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
insertUser(t, db, model.User{ID: "u-bob", Account: "bob", EngName: "Bob", ChineseName: "鮑伯"})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-bob", User: model.SubscriptionUser{ID: "u-bob", Account: "bob"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: base,
})
@@ -605,11 +643,11 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
insertUser(t, db, model.User{ID: "u-alice", Account: "alice", EngName: "Alice Wang", ChineseName: "愛麗絲"})
insertUser(t, db, model.User{ID: "u-bob", Account: "bob", EngName: "Bob", ChineseName: "鮑伯"})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-a", User: model.SubscriptionUser{ID: "u-alice", Account: "alice"},
RoomID: "r1", Roles: []model.Role{model.RoleOwner}, JoinedAt: base.Add(10 * time.Second),
})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-b", User: model.SubscriptionUser{ID: "u-bob", Account: "bob"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: base.Add(20 * time.Second),
})
@@ -634,7 +672,7 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
base := time.Date(2026, 8, 5, 0, 0, 0, 0, time.UTC)
insertUser(t, db, model.User{ID: "u-alice", Account: "alice", EngName: "Alice Wang", ChineseName: "愛麗絲"})
- insertSub(t, store, model.Subscription{
+ insertSub(t, db, model.Subscription{
ID: "sub-alice", User: model.SubscriptionUser{ID: "u-alice", Account: "alice"},
RoomID: "r1", Roles: []model.Role{model.RoleOwner}, JoinedAt: base,
})
@@ -689,6 +727,37 @@ func TestMongoStore_ListRoomMembers_Enrich_Integration(t *testing.T) {
assert.Equal(t, bare[0].ID, enriched[0].ID)
assert.Equal(t, bare[0].Member.Type, enriched[0].Member.Type)
})
+
+ // Bug 4 regression: when an org overlaps as both dept and sect, the
+ // service-side enrichment used to pick the dept branch unconditionally and
+ // drop the sect names — so a dept row with empty deptName collapsed to the
+ // orgID fallback while the worker's two-pass tiebreak rendered the sect
+ // name. The spec requires byte-identical output across both paths.
+ t.Run("org dept-first tiebreak falls back to sect names when dept names are empty", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ base := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
+
+ // One user with deptId="X" + empty deptName; one with sectId="X" +
+ // sectName="Engineering". The worker's dept-first-with-fallback logic
+ // renders "Engineering"; the service must match exactly.
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice",
+ DeptID: "X", DeptName: "", DeptTCName: "",
+ })
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob",
+ SectID: "X", SectName: "Engineering", SectTCName: "",
+ })
+ insertRM(t, db, model.RoomMember{
+ ID: "rm-org-X", RoomID: "r1", Ts: base,
+ Member: model.RoomMemberEntry{ID: "X", Type: model.RoomMemberOrg},
+ })
+
+ got, err := store.ListRoomMembers(ctx, "r1", nil, nil, true)
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "Engineering", got[0].Member.SectName,
+ "empty dept names must fall through to sect names; spec requires room-service output to match room-worker's two-pass tiebreak")
+ })
}
func TestMongoStore_ListOrgMembers_Integration(t *testing.T) {
@@ -741,6 +810,21 @@ func TestMongoStore_ListOrgMembers_Integration(t *testing.T) {
assert.True(t, errors.Is(err, errInvalidOrg), "want errInvalidOrg in chain, got %v", err)
})
+ t.Run("returns errInvalidOrg when neither sectId nor deptId matches", func(t *testing.T) {
+ // Users carry both sectId and deptId, but neither field equals the
+ // queried orgID — guards against an accidental match on the wrong
+ // branch of the $or (e.g. a future query rewrite that collapses to
+ // $or:[{sectId:...},{deptId:...}] with the wrong field).
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SectID: "sect-eng", DeptID: "dept-fe"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", SectID: "sect-ops", DeptID: "dept-be"})
+
+ _, err := store.ListOrgMembers(ctx, "sect-nope")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, errInvalidOrg), "want errInvalidOrg in chain, got %v", err)
+ })
+
t.Run("returns expected OrgMember shape", func(t *testing.T) {
db := setupMongo(t)
store := NewMongoStore(db)
@@ -761,6 +845,143 @@ func TestMongoStore_ListOrgMembers_Integration(t *testing.T) {
assert.Equal(t, "愛麗絲", m.ChineseName)
assert.Equal(t, "site-a", m.SiteID)
})
+
+ t.Run("matches by deptId", func(t *testing.T) {
+ // A dept-scoped org: room_members stores member.id = deptId. The
+ // query must find users by deptId, not sectId alone — symmetric to
+ // the GetUserWithMembership / GetSubscriptionWithMembership fixes
+ // in the dept-aware membership pass.
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", EngName: "Alice", SiteID: "site-a", SectID: "sect-eng", DeptID: "dept-fe"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", EngName: "Bob", SiteID: "site-a", SectID: "sect-eng", DeptID: "dept-fe"})
+ insertUser(t, db, model.User{ID: "u-carol", Account: "carol", EngName: "Carol", SiteID: "site-a", SectID: "sect-eng", DeptID: "dept-be"})
+
+ got, err := store.ListOrgMembers(ctx, "dept-fe")
+ require.NoError(t, err)
+ require.Len(t, got, 2)
+ accounts := []string{got[0].Account, got[1].Account}
+ assert.ElementsMatch(t, []string{"alice", "bob"}, accounts)
+ })
+
+ t.Run("matches dept users when orgId equals deptId without parent sect match", func(t *testing.T) {
+ // Truly dept-only: alice's sectId does NOT equal the orgID, so a
+ // regression that dropped the deptId branch would no longer find her.
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", SectID: "sect-other", DeptID: "dept-x"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", SiteID: "site-a", SectID: "sect-other", DeptID: ""})
+
+ got, err := store.ListOrgMembers(ctx, "dept-x")
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "alice", got[0].Account)
+ })
+}
+
+func TestMongoStore_FindExistingOrgIDs_Integration(t *testing.T) {
+ ctx := context.Background()
+
+ insertUser := func(t *testing.T, db *mongo.Database, u model.User) {
+ t.Helper()
+ _, err := db.Collection("users").InsertOne(ctx, u)
+ require.NoError(t, err)
+ }
+
+ t.Run("returns sectId and deptId matches as a set", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", SectID: "sect-eng", DeptID: "dept-fe"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", SiteID: "site-a", SectID: "sect-ops", DeptID: "dept-be"})
+
+ got, err := store.FindExistingOrgIDs(ctx, []string{"sect-eng", "dept-be", "missing"})
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"sect-eng", "dept-be"}, got)
+ })
+
+ t.Run("returns empty when no org matches", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", SectID: "sect-eng", DeptID: "dept-fe"})
+
+ got, err := store.FindExistingOrgIDs(ctx, []string{"phantom-1", "phantom-2"})
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ })
+
+ t.Run("empty input is a no-op", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ got, err := store.FindExistingOrgIDs(ctx, nil)
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ })
+
+ t.Run("orgId equal to deptId only (no parent sect) still resolves", func(t *testing.T) {
+ // Truly dept-only: alice's sectId does NOT equal the orgID, so the
+ // existence check must find her via the deptId branch alone.
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", SectID: "sect-other", DeptID: "dept-x"})
+
+ got, err := store.FindExistingOrgIDs(ctx, []string{"dept-x"})
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"dept-x"}, got)
+ })
+
+ t.Run("orgId matched by sect on one user and dept on another dedupes", func(t *testing.T) {
+ // Same orgID "foo" lands on both the sectId branch (via alice) and
+ // the deptId branch (via bob). The dedup contract says it shows up
+ // once in the result, not twice.
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", SectID: "foo", DeptID: "dept-a"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", SiteID: "site-a", SectID: "sect-b", DeptID: "foo"})
+
+ got, err := store.FindExistingOrgIDs(ctx, []string{"foo"})
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"foo"}, got)
+ assert.Len(t, got, 1, "overlapping sect+dept match must appear exactly once")
+ })
+}
+
+func TestMongoStore_FindExistingAccounts_Integration(t *testing.T) {
+ ctx := context.Background()
+
+ insertUser := func(t *testing.T, db *mongo.Database, u model.User) {
+ t.Helper()
+ _, err := db.Collection("users").InsertOne(ctx, u)
+ require.NoError(t, err)
+ }
+
+ t.Run("returns the matching subset", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a"})
+ insertUser(t, db, model.User{ID: "u-bob", Account: "bob", SiteID: "site-a"})
+
+ got, err := store.FindExistingAccounts(ctx, []string{"alice", "bob", "ghost"})
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"alice", "bob"}, got)
+ })
+
+ t.Run("returns empty when no account matches", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ insertUser(t, db, model.User{ID: "u-alice", Account: "alice", SiteID: "site-a"})
+
+ got, err := store.FindExistingAccounts(ctx, []string{"ghost-1", "ghost-2"})
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ })
+
+ t.Run("empty input is a no-op", func(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ got, err := store.FindExistingAccounts(ctx, nil)
+ require.NoError(t, err)
+ assert.Empty(t, got)
+ })
}
func TestMongoStore_ListRoomsByIDs(t *testing.T) {
@@ -782,9 +1003,7 @@ func TestMongoStore_ListRoomsByIDs(t *testing.T) {
{ID: "r5", Name: "five", Type: model.RoomTypeChannel, SiteID: "site-a", LastMsgAt: &t5},
}
for i := range seed {
- if err := store.CreateRoom(ctx, &seed[i]); err != nil {
- t.Fatalf("seed CreateRoom %q: %v", seed[i].ID, err)
- }
+ mustInsertRoom(t, db, &seed[i])
}
t.Run("returns matches and skips missing", func(t *testing.T) {
@@ -834,10 +1053,10 @@ func TestAddMembers_SameSiteChannel_RoomMembersPath(t *testing.T) {
ctx := context.Background()
// Target room on site-a
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}))
+ mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"})
// Source channel on site-a: seed room_members explicitly so ListRoomMembers takes the room_members
// branch (not the subscriptions fallback); also seed users so ResolveAccounts can find them.
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"}))
+ mustInsertRoom(t, db, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"})
_, err := db.Collection("users").InsertMany(ctx, []interface{}{
model.User{ID: "u1", Account: "bob", SiteID: "site-a"},
model.User{ID: "u2", Account: "carol", SiteID: "site-a"},
@@ -854,10 +1073,10 @@ func TestAddMembers_SameSiteChannel_RoomMembersPath(t *testing.T) {
require.NoError(t, err)
// Subscriptions: requester must be subscribed on both rooms; the source room's subscriptions
// collection stays in sync with room_members so existing-subscription filtering works downstream.
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s3", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s4", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}}))
+ mustInsertSub(t, db, &model.Subscription{ID: "s1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s3", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s4", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}})
// Same-site only: pass nil for memberListClient — the same-site branch in
// expandChannelRefs uses store.ListRoomMembers and never invokes the client.
@@ -906,8 +1125,8 @@ func TestAddMembers_SameSiteChannel_SubscriptionsFallback(t *testing.T) {
ctx := context.Background()
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}))
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"}))
+ mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"})
+ mustInsertRoom(t, db, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"})
// Seed users so ResolveAccounts can find them.
_, err := db.Collection("users").InsertMany(ctx, []interface{}{
model.User{ID: "u1", Account: "bob", SiteID: "site-a"},
@@ -917,12 +1136,12 @@ func TestAddMembers_SameSiteChannel_SubscriptionsFallback(t *testing.T) {
})
require.NoError(t, err)
// Source only has subscriptions (no room_members rows) — ListRoomMembers falls back to subscriptions
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s3", RoomID: "source", User: model.SubscriptionUser{ID: "u3", Account: "dave"}}))
+ mustInsertSub(t, db, &model.Subscription{ID: "s1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s3", RoomID: "source", User: model.SubscriptionUser{ID: "u3", Account: "dave"}})
// Requester in both
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s4", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s5", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}}))
+ mustInsertSub(t, db, &model.Subscription{ID: "s4", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}})
+ mustInsertSub(t, db, &model.Subscription{ID: "s5", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}})
// Same-site only: nil memberListClient is safe (the same-site branch never invokes it).
var publishedSubj string
@@ -967,10 +1186,10 @@ func TestAddMembers_RequesterNotSubscribed_Rejected(t *testing.T) {
ctx := context.Background()
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}))
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"}))
+ mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"})
+ mustInsertRoom(t, db, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"})
// Requester subscribed to target but NOT source
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}))
+ mustInsertSub(t, db, &model.Subscription{RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}})
// Same-site only: nil memberListClient is safe — request fails on the same-site
// GetSubscription check before reaching the cross-site branch.
@@ -1007,26 +1226,26 @@ func TestAddMembers_TwoSiteEndToEnd(t *testing.T) {
ctx := context.Background()
// Site-A: target room; requester subscribed; user document needed for ResolveAccounts.
- require.NoError(t, storeA.CreateRoom(ctx, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}))
+ mustInsertRoom(t, dbA, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"})
_, err = dbA.Collection("users").InsertMany(ctx, []interface{}{
model.User{ID: "req", Account: "alice", SiteID: "site-a"},
model.User{ID: "u1", Account: "bob", SiteID: "site-a"},
model.User{ID: "u2", Account: "carol", SiteID: "site-a"},
})
require.NoError(t, err)
- require.NoError(t, storeA.CreateSubscription(ctx, &model.Subscription{ID: "sa1", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}))
+ mustInsertSub(t, dbA, &model.Subscription{ID: "sa1", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}})
// Site-B: source channel with members; requester subscribed on site-b too.
- require.NoError(t, storeB.CreateRoom(ctx, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-b"}))
+ mustInsertRoom(t, dbB, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-b"})
_, err = dbB.Collection("users").InsertMany(ctx, []interface{}{
model.User{ID: "u1", Account: "bob", SiteID: "site-b"},
model.User{ID: "u2", Account: "carol", SiteID: "site-b"},
model.User{ID: "req", Account: "alice", SiteID: "site-b"},
})
require.NoError(t, err)
- require.NoError(t, storeB.CreateSubscription(ctx, &model.Subscription{ID: "sb1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}}))
- require.NoError(t, storeB.CreateSubscription(ctx, &model.Subscription{ID: "sb2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}}))
- require.NoError(t, storeB.CreateSubscription(ctx, &model.Subscription{ID: "sb3", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}}))
+ mustInsertSub(t, dbB, &model.Subscription{ID: "sb1", RoomID: "source", User: model.SubscriptionUser{ID: "u1", Account: "bob"}})
+ mustInsertSub(t, dbB, &model.Subscription{ID: "sb2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}})
+ mustInsertSub(t, dbB, &model.Subscription{ID: "sb3", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}})
// Site-B handler registers member.list endpoint (RegisterCRUD subscribes to MemberListWildcard).
handlerB := NewHandler(storeB, keyStore, nil, nil, "site-b", 1000, 500, 5*time.Second, func(context.Context, string, []byte) error { return nil })
@@ -1090,8 +1309,8 @@ func TestAddMembers_CrossSiteTimeout(t *testing.T) {
ctx := context.Background()
// Target on site-a, requester subscribed.
- require.NoError(t, store.CreateRoom(ctx, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{ID: "s1", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}))
+ mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"})
+ mustInsertSub(t, db, &model.Subscription{ID: "s1", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}})
// Register a site-b responder that sleeps past the client timeout, so we actually
// exercise the context.WithTimeout path (not NATS "no responders" fast-fail).
@@ -1142,7 +1361,7 @@ func TestRoomsInfoBatchRPC(t *testing.T) {
{ID: "r3", Name: "room-3", Type: model.RoomTypeChannel, SiteID: "site-a", LastMsgAt: &earlier},
}
for i := range rooms {
- require.NoError(t, store.CreateRoom(ctx, &rooms[i]))
+ mustInsertRoom(t, db, &rooms[i])
}
privKey1 := bytes.Repeat([]byte{0x01}, 32)
@@ -1387,14 +1606,14 @@ func TestMongoStore_UpdateSubscriptionRead_Integration(t *testing.T) {
store := NewMongoStore(db)
require.NoError(t, store.EnsureIndexes(ctx))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s1",
User: model.SubscriptionUser{ID: "u1", Account: "alice"},
RoomID: "r1",
SiteID: "site-a",
JoinedAt: time.Now().UTC().Add(-time.Hour),
Alert: true,
- }))
+ })
now := time.Now().UTC().Truncate(time.Millisecond)
require.NoError(t, store.UpdateSubscriptionRead(ctx, "r1", "alice", now, false))
@@ -1443,19 +1662,19 @@ func TestMongoStore_MinSubscriptionLastSeenByRoomID_Integration(t *testing.T) {
// (no lastSeenAt). The unread sub MUST be excluded — being invited into a
// room doesn't mean the user has read anything, so its joinedAt must not
// pull the room floor down.
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: "alice"},
RoomID: "r1", JoinedAt: earliest, LastSeenAt: &mid,
- }))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ })
+ mustInsertSub(t, db, &model.Subscription{
ID: "s2", User: model.SubscriptionUser{ID: "u2", Account: "bob"},
RoomID: "r1", JoinedAt: earliest, LastSeenAt: &latest,
- }))
+ })
// Never-read sub: joined at `earliest` but never opened the room.
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s3", User: model.SubscriptionUser{ID: "u3", Account: "carol"},
RoomID: "r1", JoinedAt: earliest,
- }))
+ })
got, err := store.MinSubscriptionLastSeenByRoomID(ctx, "r1")
require.NoError(t, err)
@@ -1464,10 +1683,10 @@ func TestMongoStore_MinSubscriptionLastSeenByRoomID_Integration(t *testing.T) {
// Room with subs but none has lastSeenAt — return nil so the caller can
// $unset rooms.minUserLastSeenAt.
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s4", User: model.SubscriptionUser{ID: "u4", Account: "dave"},
RoomID: "r2", JoinedAt: earliest,
- }))
+ })
got, err = store.MinSubscriptionLastSeenByRoomID(ctx, "r2")
require.NoError(t, err)
assert.Nil(t, got)
@@ -1484,9 +1703,9 @@ func TestMongoStore_UpdateRoomMinUserLastSeenAt_Integration(t *testing.T) {
store := NewMongoStore(db)
now := time.Now().UTC().Truncate(time.Millisecond)
- require.NoError(t, store.CreateRoom(ctx, &model.Room{
+ mustInsertRoom(t, db, &model.Room{
ID: "r1", Name: "x", Type: model.RoomTypeChannel, CreatedAt: now, UpdatedAt: now,
- }))
+ })
require.NoError(t, store.UpdateRoomMinUserLastSeenAt(ctx, "r1", &now))
r, err := store.GetRoom(ctx, "r1")
@@ -1500,6 +1719,26 @@ func TestMongoStore_UpdateRoomMinUserLastSeenAt_Integration(t *testing.T) {
assert.Nil(t, r.MinUserLastSeenAt)
}
+func TestMongoStore_CountNewMembers_OrgOnlyUserCountsZero_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", SectID: "org-eng", SiteID: "site-a"})
+ // Alice already has a subscription via org-eng — adding her individually should add 0 new subs.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel,
+ })
+
+ n, err := store.CountNewMembers(ctx, nil, []string{"alice"}, roomID, "")
+ require.NoError(t, err)
+ assert.Equal(t, 0, n, "alice already has a sub via org — capacity unchanged")
+}
+
func TestMongoStore_ListReadReceipts_Integration(t *testing.T) {
ctx := context.Background()
db := setupMongo(t)
@@ -1534,3 +1773,66 @@ func TestMongoStore_ListReadReceipts_Integration(t *testing.T) {
require.Empty(t, rows)
}
+// TestMongoStore_ListRoomMembers_OrgDisplay_DeptFirst_Integration verifies that
+// when an org member's id matches both a user's deptId and another user's
+// sectId, the dept branch wins and the combined "name tcName" string is
+// surfaced via Member.SectName (the existing wire field for org display).
+func TestMongoStore_ListRoomMembers_OrgDisplay_DeptFirst_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ _, err := db.Collection("rooms").InsertOne(ctx, model.Room{
+ ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "R",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("users").InsertOne(ctx, model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering", DeptTCName: "工程部",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("users").InsertOne(ctx, model.User{
+ ID: "u_bob", Account: "bob", SiteID: "site-a",
+ SectID: "X", SectName: "Sect", SectTCName: "組",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "X", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListRoomMembers(ctx, roomID, nil, nil, true)
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "Engineering 工程部", got[0].Member.SectName, "dept wins on overlap; name+tcName combined")
+}
+
+// TestMongoStore_ListRoomMembers_OrgDisplay_FallbackToOrgId_Integration verifies
+// that when no users match the org id at all (neither deptId nor sectId), the
+// display string falls back to the raw member.id rather than emitting an empty
+// string — matching displayfmt.CombineWithFallback's third-argument semantics.
+func TestMongoStore_ListRoomMembers_OrgDisplay_FallbackToOrgId_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ require.NoError(t, store.EnsureIndexes(ctx))
+
+ const roomID = "room-1"
+ _, err := db.Collection("rooms").InsertOne(ctx, model.Room{
+ ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "R",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "Y", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListRoomMembers(ctx, roomID, nil, nil, true)
+ require.NoError(t, err)
+ require.Len(t, got, 1)
+ assert.Equal(t, "Y", got[0].Member.SectName, "no matching users → falls back to member.id")
+}
diff --git a/room-service/mock_store_test.go b/room-service/mock_store_test.go
index 7c896ac4f..d0f843feb 100644
--- a/room-service/mock_store_test.go
+++ b/room-service/mock_store_test.go
@@ -88,47 +88,49 @@ func (mr *MockRoomStoreMockRecorder) CountOwners(ctx, roomID any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountOwners", reflect.TypeOf((*MockRoomStore)(nil).CountOwners), ctx, roomID)
}
-// CreateRoom mocks base method.
-func (m *MockRoomStore) CreateRoom(ctx context.Context, room *model.Room) error {
+// FindDMSubscription mocks base method.
+func (m *MockRoomStore) FindDMSubscription(ctx context.Context, account, targetName string) (*model.Subscription, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateRoom", ctx, room)
- ret0, _ := ret[0].(error)
- return ret0
+ ret := m.ctrl.Call(m, "FindDMSubscription", ctx, account, targetName)
+ ret0, _ := ret[0].(*model.Subscription)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
}
-// CreateRoom indicates an expected call of CreateRoom.
-func (mr *MockRoomStoreMockRecorder) CreateRoom(ctx, room any) *gomock.Call {
+// FindDMSubscription indicates an expected call of FindDMSubscription.
+func (mr *MockRoomStoreMockRecorder) FindDMSubscription(ctx, account, targetName any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoom", reflect.TypeOf((*MockRoomStore)(nil).CreateRoom), ctx, room)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindDMSubscription", reflect.TypeOf((*MockRoomStore)(nil).FindDMSubscription), ctx, account, targetName)
}
-// CreateSubscription mocks base method.
-func (m *MockRoomStore) CreateSubscription(ctx context.Context, sub *model.Subscription) error {
+// FindExistingAccounts mocks base method.
+func (m *MockRoomStore) FindExistingAccounts(ctx context.Context, accounts []string) ([]string, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateSubscription", ctx, sub)
- ret0, _ := ret[0].(error)
- return ret0
+ ret := m.ctrl.Call(m, "FindExistingAccounts", ctx, accounts)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
}
-// CreateSubscription indicates an expected call of CreateSubscription.
-func (mr *MockRoomStoreMockRecorder) CreateSubscription(ctx, sub any) *gomock.Call {
+// FindExistingAccounts indicates an expected call of FindExistingAccounts.
+func (mr *MockRoomStoreMockRecorder) FindExistingAccounts(ctx, accounts any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSubscription", reflect.TypeOf((*MockRoomStore)(nil).CreateSubscription), ctx, sub)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindExistingAccounts", reflect.TypeOf((*MockRoomStore)(nil).FindExistingAccounts), ctx, accounts)
}
-// FindDMSubscription mocks base method.
-func (m *MockRoomStore) FindDMSubscription(ctx context.Context, account, targetName string) (*model.Subscription, error) {
+// FindExistingOrgIDs mocks base method.
+func (m *MockRoomStore) FindExistingOrgIDs(ctx context.Context, orgIDs []string) ([]string, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "FindDMSubscription", ctx, account, targetName)
- ret0, _ := ret[0].(*model.Subscription)
+ ret := m.ctrl.Call(m, "FindExistingOrgIDs", ctx, orgIDs)
+ ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
-// FindDMSubscription indicates an expected call of FindDMSubscription.
-func (mr *MockRoomStoreMockRecorder) FindDMSubscription(ctx, account, targetName any) *gomock.Call {
+// FindExistingOrgIDs indicates an expected call of FindExistingOrgIDs.
+func (mr *MockRoomStoreMockRecorder) FindExistingOrgIDs(ctx, orgIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindDMSubscription", reflect.TypeOf((*MockRoomStore)(nil).FindDMSubscription), ctx, account, targetName)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindExistingOrgIDs", reflect.TypeOf((*MockRoomStore)(nil).FindExistingOrgIDs), ctx, orgIDs)
}
// GetApp mocks base method.
diff --git a/room-service/store.go b/room-service/store.go
index 81b23d4d3..6a70f47eb 100644
--- a/room-service/store.go
+++ b/room-service/store.go
@@ -41,12 +41,10 @@ type ReadReceiptRow struct {
}
type RoomStore interface {
- CreateRoom(ctx context.Context, room *model.Room) error
GetRoom(ctx context.Context, id string) (*model.Room, error)
ListRooms(ctx context.Context) ([]model.Room, error)
ListRoomsByIDs(ctx context.Context, ids []string) ([]model.Room, error)
GetSubscription(ctx context.Context, account, roomID string) (*model.Subscription, error)
- CreateSubscription(ctx context.Context, sub *model.Subscription) error
GetSubscriptionWithMembership(ctx context.Context, roomID, account string) (*SubscriptionWithMembership, error)
CountMembersAndOwners(ctx context.Context, roomID string) (*RoomCounts, error)
CountOwners(ctx context.Context, roomID string) (int, error)
@@ -64,10 +62,24 @@ type RoomStore interface {
// $lookup stages against users and subscriptions. When enrich=false,
// display fields are left zero.
ListRoomMembers(ctx context.Context, roomID string, limit, offset *int, enrich bool) ([]model.RoomMember, error)
- // ListOrgMembers returns all users whose sectId equals orgID, projected
- // as OrgMember rows sorted by account ascending. Returns errInvalidOrg
- // when no users match (treated as "orgId is not valid").
+ // ListOrgMembers returns all users whose sectId OR deptId equals orgID,
+ // projected as OrgMember rows sorted by account ascending. Returns
+ // errInvalidOrg when no users match (treated as "orgId is not valid").
ListOrgMembers(ctx context.Context, orgID string) ([]model.OrgMember, error)
+ // FindExistingOrgIDs returns the subset of orgIDs that match at least
+ // one user via sectId or deptId. Used by handleAddMembers and
+ // handleCreateRoomChannel to reject requests carrying phantom org IDs
+ // before they reach the canonical stream — without this gate the
+ // worker would write a room_members row and fan out a "members added"
+ // system message for an org with zero backing users.
+ FindExistingOrgIDs(ctx context.Context, orgIDs []string) ([]string, error)
+ // FindExistingAccounts returns the subset of accounts that have a
+ // matching user document. Same shape and motivation as
+ // FindExistingOrgIDs but at the user dimension — without this gate, a
+ // typo'd or fake account in req.Users is silently dropped by the
+ // candidates pipeline and the async job reports success despite the
+ // requested user never being added.
+ FindExistingAccounts(ctx context.Context, accounts []string) ([]string, error)
// UpdateSubscriptionRead sets lastSeenAt and alert on the subscription
// keyed by (roomID, account). Returns model.ErrSubscriptionNotFound
// (wrapped) when no subscription matches.
diff --git a/room-service/store_mongo.go b/room-service/store_mongo.go
index eb536f655..da2ddfe7e 100644
--- a/room-service/store_mongo.go
+++ b/room-service/store_mongo.go
@@ -10,6 +10,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
+ "github.com/hmchangw/chat/pkg/displayfmt"
"github.com/hmchangw/chat/pkg/model"
"github.com/hmchangw/chat/pkg/pipelines"
)
@@ -70,6 +71,11 @@ func (s *MongoStore) EnsureIndexes(ctx context.Context) error {
}); err != nil {
return fmt.Errorf("ensure users (sectId,account) index: %w", err)
}
+ if _, err := s.users.Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "deptId", Value: 1}, {Key: "account", Value: 1}},
+ }); err != nil {
+ return fmt.Errorf("ensure users (deptId,account) index: %w", err)
+ }
// Lookup index for botDM creation: GetApp filters by assistant.name.
appsIndex := mongo.IndexModel{
Keys: bson.D{{Key: "assistant.name", Value: 1}},
@@ -93,11 +99,6 @@ func (s *MongoStore) EnsureIndexes(ctx context.Context) error {
return nil
}
-func (s *MongoStore) CreateRoom(ctx context.Context, room *model.Room) error {
- _, err := s.rooms.InsertOne(ctx, room)
- return err
-}
-
func (s *MongoStore) GetRoom(ctx context.Context, id string) (*model.Room, error) {
var room model.Room
if err := s.rooms.FindOne(ctx, bson.M{"_id": id}).Decode(&room); err != nil {
@@ -130,11 +131,6 @@ func (s *MongoStore) GetSubscription(ctx context.Context, account, roomID string
return &sub, nil
}
-func (s *MongoStore) CreateSubscription(ctx context.Context, sub *model.Subscription) error {
- _, err := s.subscriptions.InsertOne(ctx, sub)
- return err
-}
-
// GetSubscriptionWithMembership loads the target subscription joined with their
// individual and org membership sources. Used by the remove-member validation
// flow to decide whether a user can leave or be removed individually.
@@ -160,18 +156,29 @@ func (s *MongoStore) GetSubscriptionWithMembership(ctx context.Context, roomID,
"pipeline": bson.A{
bson.M{"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$account", "$$acct"}}}},
bson.M{"$limit": 1},
- bson.M{"$project": bson.M{"sectId": 1}},
+ bson.M{"$project": bson.M{"sectId": 1, "deptId": 1}},
},
"as": "userDoc",
}}},
+ // Dept-aware org-membership lookup: a user added via Orgs:["X"] may
+ // match the org by deptId only (no sectId), so the room_members row
+ // has member.id = deptId. Checking only sectId would miss that case
+ // and report HasOrgMembership=false, leading the remove flow to drop
+ // the user's subscription even though they are still org-attached.
{{Key: "$lookup", Value: bson.M{
"from": "room_members",
- "let": bson.M{"sectId": bson.M{"$arrayElemAt": bson.A{"$userDoc.sectId", 0}}},
+ "let": bson.M{
+ "sectId": bson.M{"$arrayElemAt": bson.A{"$userDoc.sectId", 0}},
+ "deptId": bson.M{"$arrayElemAt": bson.A{"$userDoc.deptId", 0}},
+ },
"pipeline": bson.A{
bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
bson.M{"$eq": bson.A{"$rid", roomID}},
bson.M{"$eq": bson.A{"$member.type", "org"}},
- bson.M{"$eq": bson.A{"$member.id", "$$sectId"}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$member.id", "$$sectId"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$deptId"}},
+ }},
}}}},
bson.M{"$limit": 1},
},
@@ -286,11 +293,8 @@ func (s *MongoStore) CountNewMembers(ctx context.Context, orgIDs, directAccounts
if len(orgIDs) == 0 && len(directAccounts) == 0 {
return 0, nil
}
-
pipeline := pipelines.GetNewMembersPipeline(orgIDs, directAccounts, roomID, excludeAccount)
- pipeline = append(pipeline, bson.M{
- "$count": "n",
- })
+ pipeline = append(pipeline, bson.M{"$count": "n"})
cursor, err := s.users.Aggregate(ctx, pipeline)
if err != nil {
@@ -386,8 +390,33 @@ func (s *MongoStore) getRoomMembers(ctx context.Context, roomID string, limit, o
rm.Member.EngName = d.EngName
rm.Member.ChineseName = d.ChineseName
rm.Member.IsOwner = d.IsOwner
- rm.Member.SectName = d.SectName
rm.Member.MemberCount = d.MemberCount
+ // Org rows resolve display Go-side using the two-pass dept-first
+ // tiebreak that mirrors room-worker's processRemoveOrg exactly:
+ //
+ // 1. Prefer dept names when a dept match exists AND has non-empty
+ // name/tcName.
+ // 2. Otherwise fall back to the sect names (which the aggregation
+ // now retains alongside the dept names — see the $group stage).
+ // 3. CombineWithFallback handles the both-empty case by emitting
+ // the member.id, matching the worker's displayOrg/orgID fallback.
+ //
+ // Without the explicit fallback on empty dept names, a row with
+ // IsDept=true but empty deptName + a sibling row with sectName="Eng"
+ // would render the orgID server-side while the worker emits "Eng" —
+ // the spec requires byte-identical output between the two paths.
+ if rm.Member.Type == model.RoomMemberOrg {
+ var name, tcName string
+ if d.OrgRaw != nil {
+ if d.OrgRaw.IsDept && (d.OrgRaw.DeptName != "" || d.OrgRaw.DeptTCName != "") {
+ name, tcName = d.OrgRaw.DeptName, d.OrgRaw.DeptTCName
+ }
+ if name == "" && tcName == "" {
+ name, tcName = d.OrgRaw.SectName, d.OrgRaw.SectTCName
+ }
+ }
+ rm.Member.SectName = displayfmt.CombineWithFallback(name, tcName, rm.Member.ID)
+ }
members = append(members, rm)
}
return members, nil
@@ -406,11 +435,25 @@ type roomMemberEnrichedRow struct {
}
type roomMemberEnrichedDisplay struct {
- EngName string `bson:"engName,omitempty"`
- ChineseName string `bson:"chineseName,omitempty"`
- IsOwner bool `bson:"isOwner,omitempty"`
- SectName string `bson:"sectName,omitempty"`
- MemberCount int `bson:"memberCount,omitempty"`
+ EngName string `bson:"engName,omitempty"`
+ ChineseName string `bson:"chineseName,omitempty"`
+ IsOwner bool `bson:"isOwner,omitempty"`
+ MemberCount int `bson:"memberCount,omitempty"`
+ OrgRaw *orgRawDisplay `bson:"orgRaw,omitempty"`
+}
+
+// orgRawDisplay carries the unresolved org-lookup result (one element of the
+// `_orgMatch` group). It exists so Go-side post-processing can pick the
+// dept-vs-sect branch and run displayfmt.CombineWithFallback — keeping the
+// final display string consistent with the sys-message formatter used by
+// room-worker. A nil pointer means no user matched the org id at all, in
+// which case the loop falls back to the raw member.id.
+type orgRawDisplay struct {
+ IsDept bool `bson:"isDept,omitempty"`
+ DeptName string `bson:"deptName,omitempty"`
+ DeptTCName string `bson:"deptTCName,omitempty"`
+ SectName string `bson:"sectName,omitempty"`
+ SectTCName string `bson:"sectTCName,omitempty"`
}
// enrichRoomMembersStages returns the $lookup + $set stages appended to the
@@ -455,7 +498,12 @@ func enrichRoomMembersStages(roomID string) []bson.D {
},
"as": "_subMatch",
}}},
- // Orgs: join users on sectId = member.id → sectName + count.
+ // Orgs: join users whose deptId OR sectId matches member.id. The
+ // pipeline returns one grouped document carrying raw {isDept, deptName,
+ // deptTCName, sectName, sectTCName, memberCount}. The dept-vs-sect
+ // decision plus the localized + traditional name combine happen
+ // Go-side via displayfmt.CombineWithFallback so the output matches
+ // the sys-message formatter used by room-worker byte-for-byte.
{{Key: "$lookup", Value: bson.M{
"from": "users",
"let": bson.M{
@@ -465,20 +513,36 @@ func enrichRoomMembersStages(roomID string) []bson.D {
"pipeline": bson.A{
bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
bson.M{"$eq": bson.A{"$$mtyp", "org"}},
- bson.M{"$eq": bson.A{"$sectId", "$$orgId"}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ bson.M{"$eq": bson.A{"$sectId", "$$orgId"}},
+ }},
}}}},
- // $first:$sectName relies on the invariant that all users
- // sharing a sectId carry the same sectName; if that ever drifts,
- // the chosen name is non-deterministic without an upstream $sort.
+ bson.M{"$addFields": bson.M{
+ "_isDept": bson.M{"$eq": bson.A{"$deptId", "$$orgId"}},
+ "_name": bson.M{"$cond": bson.A{bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptName", "$sectName"}},
+ "_tcName": bson.M{"$cond": bson.A{bson.M{"$eq": bson.A{"$deptId", "$$orgId"}}, "$deptTCName", "$sectTCName"}},
+ }},
+ // $max over a bool surfaces "any user matched deptId" — when at
+ // least one dept-match exists it wins regardless of how many
+ // sect-only users join the same group. dept/sect *Name fields
+ // are gated by _isDept so the chosen branch's strings flow
+ // through and the other branch's are null-suppressed.
bson.M{"$group": bson.M{
"_id": nil,
- "sectName": bson.M{"$first": "$sectName"},
+ "isDept": bson.M{"$max": "$_isDept"},
+ "deptName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_name", nil}}},
+ "deptTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", "$_tcName", nil}}},
+ "sectName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_name"}}},
+ "sectTCName": bson.M{"$max": bson.M{"$cond": bson.A{"$_isDept", nil, "$_tcName"}}},
"memberCount": bson.M{"$sum": 1},
}},
},
"as": "_orgMatch",
}}},
// Fold the three matches into a single `display` sub-document.
+ // `orgRaw` surfaces the raw org-lookup pair for Go-side combine —
+ // nil when no users matched, triggering the orgId fallback below.
{{Key: "$set", Value: bson.M{
"display": bson.M{
"engName": bson.M{"$arrayElemAt": bson.A{"$_userMatch.engName", 0}},
@@ -490,7 +554,7 @@ func enrichRoomMembersStages(roomID string) []bson.D {
bson.A{},
}},
}},
- "sectName": bson.M{"$arrayElemAt": bson.A{"$_orgMatch.sectName", 0}},
+ "orgRaw": bson.M{"$arrayElemAt": bson.A{"$_orgMatch", 0}},
"memberCount": bson.M{"$arrayElemAt": bson.A{"$_orgMatch.memberCount", 0}},
},
}}},
@@ -629,9 +693,14 @@ func (s *MongoStore) FindDMSubscription(ctx context.Context, account, targetName
return &sub, nil
}
-// ListOrgMembers returns all users whose sectId equals orgID, projected as
-// OrgMember rows sorted by account ascending. Returns errInvalidOrg when the
-// query matches no users.
+// ListOrgMembers returns all users whose sectId OR deptId equals orgID,
+// projected as OrgMember rows sorted by account ascending. The dept branch
+// is symmetric to the membership-lookup pipelines (GetSubscriptionWithMembership,
+// GetUserWithMembership): an org added by a dept-only match stores
+// member.id = deptId in room_members, so the expansion RPC must look up
+// users by deptId too. Both (sectId, account) and (deptId, account) indexes
+// exist (see ensureIndexes) so the $or stays index-backed. Returns
+// errInvalidOrg when neither branch matches any users.
func (s *MongoStore) ListOrgMembers(ctx context.Context, orgID string) ([]model.OrgMember, error) {
opts := options.Find().
SetSort(bson.D{{Key: "account", Value: 1}}).
@@ -642,7 +711,10 @@ func (s *MongoStore) ListOrgMembers(ctx context.Context, orgID string) ([]model.
"chineseName": 1,
"siteId": 1,
})
- cursor, err := s.users.Find(ctx, bson.M{"sectId": orgID}, opts)
+ cursor, err := s.users.Find(ctx, bson.M{"$or": []bson.M{
+ {"sectId": orgID},
+ {"deptId": orgID},
+ }}, opts)
if err != nil {
return nil, fmt.Errorf("find users for org %q: %w", orgID, err)
}
@@ -658,6 +730,63 @@ func (s *MongoStore) ListOrgMembers(ctx context.Context, orgID string) ([]model.
return members, nil
}
+// FindExistingOrgIDs returns the subset of orgIDs that match at least one
+// user via sectId or deptId. Two parallel distinct calls — one on each
+// indexed field — keep the query covered by the (sectId, account) and
+// (deptId, account) compound indexes; the result of each distinct is
+// bounded by len(orgIDs) since the filter is an $in on the same field.
+//
+// A single $unionWith aggregation was tried (one round-trip instead of
+// two) and benchmarked ~8.5% faster end-to-end with the same index
+// coverage, but the aggregation form is more complex, ships ~55% more
+// Go-side allocations per call, and shifts behavior onto Mongo's
+// aggregation framework (slightly different optimizations across
+// versions, more surface area in a sharded future). The two-Distinct
+// form is simpler, version-agnostic from at least Mongo 4.4 onward, and
+// the perf delta is not material at this call rate. Keep it simple.
+func (s *MongoStore) FindExistingOrgIDs(ctx context.Context, orgIDs []string) ([]string, error) {
+ if len(orgIDs) == 0 {
+ return nil, nil
+ }
+ var sectIDs []string
+ if err := s.users.Distinct(ctx, "sectId", bson.M{"sectId": bson.M{"$in": orgIDs}}).Decode(§IDs); err != nil {
+ return nil, fmt.Errorf("distinct sectIds for org validation: %w", err)
+ }
+ var deptIDs []string
+ if err := s.users.Distinct(ctx, "deptId", bson.M{"deptId": bson.M{"$in": orgIDs}}).Decode(&deptIDs); err != nil {
+ return nil, fmt.Errorf("distinct deptIds for org validation: %w", err)
+ }
+ out := make([]string, 0, len(sectIDs)+len(deptIDs))
+ seen := make(map[string]struct{}, len(sectIDs)+len(deptIDs))
+ for _, id := range sectIDs {
+ if _, ok := seen[id]; !ok {
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+ for _, id := range deptIDs {
+ if _, ok := seen[id]; !ok {
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+ return out, nil
+}
+
+// FindExistingAccounts returns the subset of accounts that have a matching
+// user document. Distinct on the indexed `account` field keeps the result
+// bounded by len(accounts) regardless of how many users share an org.
+func (s *MongoStore) FindExistingAccounts(ctx context.Context, accounts []string) ([]string, error) {
+ if len(accounts) == 0 {
+ return nil, nil
+ }
+ var out []string
+ if err := s.users.Distinct(ctx, "account", bson.M{"account": bson.M{"$in": accounts}}).Decode(&out); err != nil {
+ return nil, fmt.Errorf("distinct accounts for user validation: %w", err)
+ }
+ return out, nil
+}
+
// UpdateSubscriptionRead sets lastSeenAt and alert on the subscription
// keyed by (roomID, account). Returns model.ErrSubscriptionNotFound when no
// subscription matches.
diff --git a/room-worker/handler.go b/room-worker/handler.go
index afd56f06d..262027e24 100644
--- a/room-worker/handler.go
+++ b/room-worker/handler.go
@@ -165,6 +165,26 @@ func sanitizeAsyncJobError(err error) string {
return "operation failed"
}
+// reconcileRoomOnDuplicateKey verifies the existing room is structurally compatible with the want spec; one source of truth for both create paths.
+// The structural check (Type + SiteID match) is sufficient: the caller's
+// BulkCreateSubscriptions runs idempotently (unique index dedups racing
+// inserts; missing inserts are completed on retry). Crucially, this means a
+// mid-write crash (CreateRoom succeeded but the worker died before
+// BulkCreateSubscriptions) is recovered by JetStream redelivery — the retry
+// finds the existing room, finishes the subscription writes, and the room
+// is not orphaned.
+func (h *Handler) reconcileRoomOnDuplicateKey(ctx context.Context, want *model.Room) (*model.Room, error) {
+ existing, err := h.store.GetRoom(ctx, want.ID)
+ if err != nil {
+ return nil, fmt.Errorf("fetch existing room on duplicate-key: %w", err)
+ }
+ if existing.Type != want.Type || existing.SiteID != want.SiteID {
+ return nil, newPermanent("room ID collision (existing type=%s site=%s; want %s/%s)",
+ existing.Type, existing.SiteID, want.Type, want.SiteID)
+ }
+ return existing, nil
+}
+
func (h *Handler) HandleJetStreamMsg(ctx context.Context, msg jetstream.Msg) {
subj := msg.Subject()
var err error
@@ -296,13 +316,11 @@ func (h *Handler) processRemoveMember(ctx context.Context, data []byte) error {
roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Get")))
return fmt.Errorf("get room key: %w", err)
}
- // Skip-rotation guard: a prior redelivery of this canonical event already rotated Valkey past req.BaseKeyVersion.
- shouldRotate := currentPair == nil || currentPair.Version <= req.BaseKeyVersion
if req.OrgID != "" {
- return h.processRemoveOrg(ctx, &req, currentPair, shouldRotate)
+ return h.processRemoveOrg(ctx, &req, currentPair)
}
- return h.processRemoveIndividual(ctx, &req, currentPair, shouldRotate)
+ return h.processRemoveIndividual(ctx, &req, currentPair)
}
// rotateAndFanOut generates v+1, fans it out to survivors, then commits via Valkey Rotate.
@@ -324,7 +342,6 @@ func (h *Handler) rotateAndFanOut(ctx context.Context, roomID string, currentPai
roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Set")))
return fmt.Errorf("store room key (no prior): %w", err)
}
- roomkeymetrics.KeyGenerated.Add(ctx, 1)
return nil
}
if _, err := h.keyStore.Rotate(ctx, roomID, *newPair); err != nil {
@@ -336,17 +353,15 @@ func (h *Handler) rotateAndFanOut(ctx context.Context, roomID string, currentPai
roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "SetWithVersion")))
return fmt.Errorf("store room key (fallback): %w", setErr)
}
- roomkeymetrics.KeyGenerated.Add(ctx, 1)
return nil
}
roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Rotate")))
return fmt.Errorf("rotate room key: %w", err)
}
- roomkeymetrics.KeyRotated.Add(ctx, 1)
return nil
}
-func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.RemoveMemberRequest, currentPair *roomkeystore.VersionedKeyPair, shouldRotate bool) (err error) {
+func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.RemoveMemberRequest, currentPair *roomkeystore.VersionedKeyPair) (err error) {
if req.Timestamp <= 0 {
req.Timestamp = time.Now().UTC().UnixMilli()
}
@@ -386,14 +401,12 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove
}
// Rotate after delete + reconcile; ListByRoom returns post-deletion survivors.
- if shouldRotate {
- survivors, listErr := h.store.ListByRoom(ctx, req.RoomID)
- if listErr != nil {
- return fmt.Errorf("list survivors for key fan-out (room %s): %w", req.RoomID, listErr)
- }
- if err := h.rotateAndFanOut(ctx, req.RoomID, currentPair, survivors); err != nil {
- return err
- }
+ survivors, listErr := h.store.ListByRoom(ctx, req.RoomID)
+ if listErr != nil {
+ return fmt.Errorf("list survivors for key fan-out (room %s): %w", req.RoomID, listErr)
+ }
+ if err := h.rotateAndFanOut(ctx, req.RoomID, currentPair, survivors); err != nil {
+ return fmt.Errorf("rotate and fan-out room key after remove-individual: %w", err)
}
now := time.Now().UTC()
@@ -518,7 +531,7 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove
return nil
}
-func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberRequest, currentPair *roomkeystore.VersionedKeyPair, shouldRotate bool) (err error) {
+func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberRequest, currentPair *roomkeystore.VersionedKeyPair) (err error) {
if req.Timestamp <= 0 {
req.Timestamp = time.Now().UTC().UnixMilli()
}
@@ -532,28 +545,48 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR
return fmt.Errorf("get org members with individual status: %w", err)
}
- // SectName is harvested from the UNFILTERED members slice (not toRemove) so
- // it remains correct when every org member also has an individual sub and
- // toRemove ends up empty. Pick the first non-empty SectName; an all-empty
- // result is a data inconsistency upstream and must short-circuit before any
- // mutating store call so the org-doc deletion is not lost to a malformed
- // sys-message.
- sectName := ""
+ // Single pass: dept wins on overlap; otherwise first sect row. Stash the
+ // first sect candidate as we scan so we don't need a second pass — the
+ // dept row (if any) overrides it. Name/TCName harvested from the
+ // UNFILTERED members slice so they remain correct when every org member
+ // also has an individual sub and toRemove ends up empty. The orgID
+ // fallback in displayOrg/CombineWithFallback guarantees a non-empty
+ // rendered string even when all names are empty, so an all-empty result
+ // is no longer a permanent error.
+ var name, tcName string
+ var sectName, sectTCName string
+ var sectFound bool
for _, m := range members {
- if m.SectName != "" {
- sectName = m.SectName
+ if m.IsDept {
+ name, tcName = m.Name, m.TCName
break
}
+ if !sectFound {
+ sectName, sectTCName = m.Name, m.TCName
+ sectFound = true
+ }
}
- if sectName == "" {
- return newPermanent("org %s missing SectName on all members (room %s)", req.OrgID, req.RoomID)
+ if name == "" && tcName == "" && sectFound {
+ name, tcName = sectName, sectTCName
+ }
+ if name == "" && tcName == "" {
+ slog.Warn("org-remove: no name resolved from any member; falling back to orgID",
+ "requestID", natsutil.RequestIDFromContext(ctx),
+ "roomID", req.RoomID, "orgID", req.OrgID)
}
+ // Skip members who still have an individual row OR are still reachable
+ // via another org row in the same room. The latter matters because this
+ // PR's dept-aware matching lets the same user be covered by two org rows
+ // concurrently (one matching their sectId, one matching their deptId);
+ // removing one of those orgs must not orphan the user's subscription
+ // while the sibling row still claims them as a member.
var toRemove []OrgMemberStatus
for _, m := range members {
- if !m.HasIndividualMembership {
- toRemove = append(toRemove, m)
+ if m.HasIndividualMembership || m.HasOtherOrgMembership {
+ continue
}
+ toRemove = append(toRemove, m)
}
accounts := make([]string, len(toRemove))
@@ -576,13 +609,13 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR
}
// Rotate only when something was actually deleted; ListByRoom returns post-deletion survivors.
- if shouldRotate && len(accounts) > 0 {
+ if len(accounts) > 0 {
survivors, listErr := h.store.ListByRoom(ctx, req.RoomID)
if listErr != nil {
return fmt.Errorf("list survivors for key fan-out (room %s): %w", req.RoomID, listErr)
}
if err := h.rotateAndFanOut(ctx, req.RoomID, currentPair, survivors); err != nil {
- return err
+ return fmt.Errorf("rotate and fan-out room key after remove-org: %w", err)
}
}
@@ -644,7 +677,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR
}
sysMsgPayload, _ := json.Marshal(model.MemberRemoved{
OrgID: req.OrgID,
- SectName: sectName,
+ SectName: displayOrg(name, tcName, req.OrgID),
RemovedUsersCount: len(toRemove),
})
seed := messageDedupSeed(ctx, "processRemoveOrg", req.RoomID,
@@ -655,7 +688,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR
UserID: requester.ID,
UserAccount: requester.Account,
Type: model.MessageTypeMemberRemoved,
- Content: formatRemovedOrg(sectName),
+ Content: formatRemovedOrg(name, tcName, req.OrgID),
SysMsgData: sysMsgPayload,
CreatedAt: now,
}
@@ -733,31 +766,78 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error
return newPermanent("add-member only valid on channel rooms, got %s", room.Type)
}
- // Expand org IDs + direct accounts to actual account list, excluding already-subscribed
- accounts, err := h.store.ListNewMembers(ctx, req.Orgs, req.Users, req.RoomID)
+ // Resolve candidates and per-candidate flags (has-sub / has-individual-row).
+ // Splits the writes into needSub (no subscription yet) and needIRM (no
+ // individual room_members row yet, writeIndividuals-gated): this is what
+ // makes the org→individual upgrade path work — alice already has a sub
+ // from an earlier org expansion, but no individual row, so an explicit
+ // re-add via req.Users only needs to write the missing IRM row.
+ candidates, err := h.store.ListAddMemberCandidates(ctx, req.Orgs, req.Users, req.RoomID)
if err != nil {
- return fmt.Errorf("list new members: %w", err)
+ return fmt.Errorf("list add-member candidates: %w", err)
}
- if len(accounts) == 0 {
+
+ // Fail closed: defaulting hadOrgsBefore=false on error would trigger spurious first-org backfill.
+ hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID)
+ if err != nil {
+ return fmt.Errorf("check existing org room members: %w", err)
+ }
+ writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore
+
+ allowedIndividual := make(map[string]struct{}, len(req.Users))
+ for _, acc := range req.Users {
+ allowedIndividual[acc] = struct{}{}
+ }
+
+ // needSub = no sub yet; needIRM = no individual row yet (writeIndividuals-gated, req.Users only).
+ needSub := make([]AddMemberCandidate, 0, len(candidates))
+ needIRM := make([]AddMemberCandidate, 0, len(candidates))
+ for _, c := range candidates {
+ if !c.HasSubscription {
+ needSub = append(needSub, c)
+ }
+ if writeIndividuals && !c.HasIndividualRoomMember {
+ if _, ok := allowedIndividual[c.Account]; ok {
+ needIRM = append(needIRM, c)
+ }
+ }
+ }
+
+ // Nothing to write: no new subs, no individual upgrades, no org rows.
+ if len(needSub) == 0 && len(needIRM) == 0 && len(req.Orgs) == 0 {
return nil
}
- users, err := h.store.FindUsersByAccounts(ctx, accounts)
- if err != nil {
- return fmt.Errorf("find users by accounts: %w", err)
+ // Lookup-account set: anyone whose sub or individual row we'll write.
+ lookupAccounts := make([]string, 0, len(needSub)+len(needIRM))
+ seen := make(map[string]struct{}, len(needSub)+len(needIRM))
+ for _, c := range needSub {
+ if _, ok := seen[c.Account]; !ok {
+ lookupAccounts = append(lookupAccounts, c.Account)
+ seen[c.Account] = struct{}{}
+ }
}
- userMap := make(map[string]model.User, len(users))
- for i := range users {
- userMap[users[i].Account] = users[i]
+ for _, c := range needIRM {
+ if _, ok := seen[c.Account]; !ok {
+ lookupAccounts = append(lookupAccounts, c.Account)
+ seen[c.Account] = struct{}{}
+ }
}
- // `accounts` is the resolved set from ListNewMembers (which queries the
- // users collection), so a missing entry here means the user was deleted
- // between resolution and lookup — a hard data inconsistency that won't
- // resolve via JetStream redelivery. Mirror the create-room contract and
- // fail permanently rather than silently materializing partial membership.
- for _, account := range accounts {
- if _, ok := userMap[account]; !ok {
- return newPermanent("user %s not found in room.member.add (room %s)", account, req.RoomID)
+
+ var userMap map[string]model.User
+ if len(lookupAccounts) > 0 {
+ users, err := h.store.FindUsersByAccounts(ctx, lookupAccounts)
+ if err != nil {
+ return fmt.Errorf("find users by accounts: %w", err)
+ }
+ userMap = make(map[string]model.User, len(users))
+ for i := range users {
+ userMap[users[i].Account] = users[i]
+ }
+ for _, acc := range lookupAccounts {
+ if _, ok := userMap[acc]; !ok {
+ return newPermanent("user %s not found in room.member.add (room %s)", acc, req.RoomID)
+ }
}
}
@@ -778,17 +858,14 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error
acceptedAt := time.UnixMilli(req.Timestamp).UTC()
now := time.Now().UTC()
- // Build subscriptions and collect the resolved accounts in a single pass
- // so we don't re-iterate `subs` later to build an account set or an
- // actualAccounts slice.
- subs := make([]*model.Subscription, 0, len(accounts))
- actualAccounts := make([]string, 0, len(accounts))
- resolvedAccountSet := make(map[string]struct{}, len(accounts))
- for _, account := range accounts {
- // Presence guaranteed by the userMap completeness check above.
- user := userMap[account]
- // RoomType is fixed to channel: room-service rejects member.add for
- // any other room kind.
+ // Build subscriptions for accounts that don't yet have one. RoomType is
+ // fixed to channel: room-service rejects member.add for any other room
+ // kind. actualAccounts mirrors needSub for downstream event payloads
+ // (MemberAddEvent, sys-msg multi/single content).
+ subs := make([]*model.Subscription, 0, len(needSub))
+ actualAccounts := make([]string, 0, len(needSub))
+ for _, c := range needSub {
+ user := userMap[c.Account]
sub := &model.Subscription{
ID: idgen.GenerateUUIDv7(),
User: model.SubscriptionUser{ID: user.ID, Account: user.Account},
@@ -808,88 +885,98 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error
}
subs = append(subs, sub)
actualAccounts = append(actualAccounts, user.Account)
- resolvedAccountSet[user.Account] = struct{}{}
}
- if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil {
- return fmt.Errorf("bulk create subscriptions: %w", err)
- }
-
- // Fail closed: defaulting hadOrgsBefore=false on error would trigger spurious first-org backfill.
- hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID)
- if err != nil {
- return fmt.Errorf("check existing org room members: %w", err)
+ if len(subs) > 0 {
+ if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil {
+ return fmt.Errorf("bulk create subscriptions: %w", err)
+ }
}
- writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore
// Collect all room_member docs to write in a single bulk insert:
- // new individuals + new orgs + (optional) backfill of existing subscribers.
- roomMembers := make([]*model.RoomMember, 0, len(subs)+len(req.Orgs))
- allowedIndividual := make(map[string]struct{}, len(req.Users))
- for _, acc := range req.Users {
- allowedIndividual[acc] = struct{}{}
- }
- if writeIndividuals {
- for _, sub := range subs {
- if _, ok := allowedIndividual[sub.User.Account]; !ok {
- continue
- }
- roomMembers = append(roomMembers, &model.RoomMember{
- ID: idgen.GenerateUUIDv7(),
- RoomID: req.RoomID,
- Ts: acceptedAt,
- Member: model.RoomMemberEntry{
- ID: sub.User.ID,
- Type: model.RoomMemberIndividual,
- Account: sub.User.Account,
- },
- })
- }
- }
- for _, org := range req.Orgs {
+ // new individuals (from needSub ∩ req.Users) + individual upgrades
+ // (needIRM = req.Users with existing sub but no IRM row) + new orgs +
+ // (optional) backfill of existing subscribers. processedAccounts tracks
+ // every account we've already issued a sub or individual row for, so the
+ // backfill step below can skip them.
+ roomMembers := make([]*model.RoomMember, 0, len(needIRM)+len(req.Orgs))
+ processedAccounts := make(map[string]struct{}, len(needSub)+len(needIRM))
+ for _, c := range needSub {
+ processedAccounts[c.Account] = struct{}{}
+ }
+ for _, c := range needIRM {
+ processedAccounts[c.Account] = struct{}{}
+ user := userMap[c.Account]
roomMembers = append(roomMembers, &model.RoomMember{
ID: idgen.GenerateUUIDv7(),
RoomID: req.RoomID,
Ts: acceptedAt,
Member: model.RoomMemberEntry{
- ID: org,
- Type: model.RoomMemberOrg,
+ ID: user.ID,
+ Type: model.RoomMemberIndividual,
+ Account: user.Account,
},
})
}
+ for _, org := range req.Orgs {
+ roomMembers = append(roomMembers, &model.RoomMember{
+ ID: idgen.GenerateUUIDv7(),
+ RoomID: req.RoomID,
+ Ts: acceptedAt,
+ Member: model.RoomMemberEntry{ID: org, Type: model.RoomMemberOrg},
+ })
+ }
// Backfill existing subscribers into room_members only when orgs are
// joining for the first time and we're starting to track individuals.
+ // Backfill errors propagate: log-and-continue would silently corrupt
+ // room_members (existing subs would never get IRM rows). Retry is safe —
+ // subs are already written so needSub is empty, hadOrgsBefore stays false
+ // until BulkCreateRoomMembers commits, and the backfill re-runs cleanly.
if len(req.Orgs) > 0 && !hadOrgsBefore {
existingAccounts, err := h.store.GetSubscriptionAccounts(ctx, req.RoomID)
if err != nil {
- slog.Warn("get subscription accounts for backfill failed", "error", err)
- } else {
- var backfillAccounts []string
- for _, account := range existingAccounts {
- if _, isNew := resolvedAccountSet[account]; !isNew {
- backfillAccounts = append(backfillAccounts, account)
- }
+ return fmt.Errorf("get subscription accounts for backfill: %w", err)
+ }
+ var backfillAccounts []string
+ for _, account := range existingAccounts {
+ if _, processed := processedAccounts[account]; !processed {
+ backfillAccounts = append(backfillAccounts, account)
+ }
+ }
+ if len(backfillAccounts) > 0 {
+ backfillUsers, err := h.store.FindUsersByAccounts(ctx, backfillAccounts)
+ if err != nil {
+ return fmt.Errorf("find users for backfill: %w", err)
}
- if len(backfillAccounts) > 0 {
- backfillUsers, err := h.store.FindUsersByAccounts(ctx, backfillAccounts)
- if err != nil {
- slog.Warn("find users for backfill failed", "error", err)
- } else {
- for i := range backfillUsers {
- roomMembers = append(roomMembers, &model.RoomMember{
- ID: idgen.GenerateUUIDv7(),
- RoomID: req.RoomID,
- Ts: acceptedAt,
- Member: model.RoomMemberEntry{
- ID: backfillUsers[i].ID,
- Type: model.RoomMemberIndividual,
- Account: backfillUsers[i].Account,
- },
- })
- }
+ // Fail-hard if any requested account is missing. A partial result
+ // would commit some IRM rows + flip hadOrgsBefore=true (once
+ // BulkCreateRoomMembers writes the org row), after which no future
+ // redelivery can repair the missing rows — backfill only fires on
+ // the first-org transition. Better to halt and surface the stale
+ // sub via the async-job error than to bake permanent divergence
+ // between subscriptions and room_members.
+ found := make(map[string]struct{}, len(backfillUsers))
+ for i := range backfillUsers {
+ found[backfillUsers[i].Account] = struct{}{}
+ }
+ for _, acc := range backfillAccounts {
+ if _, ok := found[acc]; !ok {
+ return newPermanent("backfill user %s not found in room.member.add (room %s)", acc, req.RoomID)
}
}
+ for i := range backfillUsers {
+ roomMembers = append(roomMembers, &model.RoomMember{
+ ID: idgen.GenerateUUIDv7(),
+ RoomID: req.RoomID,
+ Ts: acceptedAt,
+ Member: model.RoomMemberEntry{
+ ID: backfillUsers[i].ID,
+ Type: model.RoomMemberIndividual,
+ Account: backfillUsers[i].Account,
+ },
+ })
+ }
}
}
@@ -920,78 +1007,110 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error
}
}
- if err := h.buildAndFanOutRoomKey(ctx, req.RoomID, users); err != nil {
- return fmt.Errorf("fan out room key: %w", err)
+ // Fan out the room key only to newly-subscribed accounts. Accounts in
+ // needIRM already had a subscription (and thus already received the key
+ // on their original add), so they don't need a fresh delivery here.
+ // Get is intentionally post-Mongo: the key was created at room-create
+ // time and is not re-rotated for adds, so we just fetch the current pair.
+ newSubUsers := make([]model.User, 0, len(needSub))
+ for _, c := range needSub {
+ newSubUsers = append(newSubUsers, userMap[c.Account])
}
-
- // 8. Publish MemberAddEvent (actualAccounts was built above alongside subs)
- historySharedSince := historySharedSincePtr(req.History, req.Timestamp, req.RoomID)
- memberAddEvt := model.MemberAddEvent{
- Type: "member_added",
- RoomID: req.RoomID,
- RoomName: room.Name,
- RoomType: room.Type,
- Accounts: actualAccounts,
- SiteID: room.SiteID,
- RequesterAccount: req.RequesterAccount,
- JoinedAt: req.Timestamp,
- HistorySharedSince: historySharedSince,
- Timestamp: now.UnixMilli(),
- }
- memberAddData, _ := json.Marshal(memberAddEvt)
- if err := h.publish(ctx, subject.RoomMemberEvent(req.RoomID), memberAddData, ""); err != nil {
- slog.Error("member add event publish failed", "error", err, "roomID", req.RoomID)
+ if len(newSubUsers) > 0 {
+ pair, err := h.keyStore.Get(ctx, req.RoomID)
+ if err != nil {
+ roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Get")))
+ return fmt.Errorf("get room key for fan-out: %w", err)
+ }
+ if err := h.buildAndFanOutRoomKey(ctx, req.RoomID, pair, newSubUsers); err != nil {
+ return fmt.Errorf("fan out room key: %w", err)
+ }
}
- if len(actualAccounts) > 0 {
- inboxOutbox := model.OutboxEvent{
- Type: "member_added",
- SiteID: room.SiteID,
- DestSiteID: room.SiteID,
- Payload: memberAddData,
- Timestamp: now.UnixMilli(),
+ // 8. Publish MemberAddEvent (actualAccounts was built above alongside subs).
+ // Gate on "actual membership change visible to room": new individual subs
+ // (actualAccounts) or new org rows (req.Orgs). The org→individual upgrade
+ // path (only needIRM populated) writes the missing individual room_members
+ // row silently — no membership state changed for the room itself, so
+ // emitting an empty MemberAddEvent and a "added members to the channel"
+ // sys-msg with no actual members listed would mislead end users.
+ historySharedSince := historySharedSincePtr(req.History, req.Timestamp, req.RoomID)
+ if len(actualAccounts) > 0 || len(req.Orgs) > 0 {
+ memberAddEvt := model.MemberAddEvent{
+ Type: "member_added",
+ RoomID: req.RoomID,
+ RoomName: room.Name,
+ RoomType: room.Type,
+ Accounts: actualAccounts,
+ SiteID: room.SiteID,
+ RequesterAccount: req.RequesterAccount,
+ JoinedAt: req.Timestamp,
+ HistorySharedSince: historySharedSince,
+ Timestamp: now.UnixMilli(),
}
- inboxData, _ := json.Marshal(inboxOutbox)
- inboxSeed := fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp)
- if err := h.publish(ctx, subject.InboxMemberAdded(room.SiteID), inboxData, natsutil.OutboxDedupID(ctx, room.SiteID, inboxSeed)); err != nil {
- slog.Error("local inbox member_added publish failed", "error", err, "roomID", req.RoomID)
+ memberAddData, _ := json.Marshal(memberAddEvt)
+ if err := h.publish(ctx, subject.RoomMemberEvent(req.RoomID), memberAddData, ""); err != nil {
+ slog.Error("member add event publish failed",
+ "error", err,
+ "roomID", req.RoomID,
+ "requestID", natsutil.RequestIDFromContext(ctx),
+ )
+ }
+
+ if len(actualAccounts) > 0 {
+ inboxOutbox := model.OutboxEvent{
+ Type: "member_added",
+ SiteID: room.SiteID,
+ DestSiteID: room.SiteID,
+ Payload: memberAddData,
+ Timestamp: now.UnixMilli(),
+ }
+ inboxData, _ := json.Marshal(inboxOutbox)
+ inboxSeed := fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp)
+ if err := h.publish(ctx, subject.InboxMemberAdded(room.SiteID), inboxData, natsutil.OutboxDedupID(ctx, room.SiteID, inboxSeed)); err != nil {
+ slog.Error("local inbox member_added publish failed",
+ "error", err,
+ "roomID", req.RoomID,
+ "requestID", natsutil.RequestIDFromContext(ctx),
+ )
+ }
}
- }
- membersAdded := model.MembersAdded{
- Individuals: actualAccounts,
- Orgs: req.Orgs,
- Channels: req.Channels,
- AddedUsersCount: len(subs),
- }
- sysMsgData, _ := json.Marshal(membersAdded)
- seed := messageDedupSeed(ctx, "processAddMembers", req.RoomID,
- fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp))
- // Single form only for direct 1-user adds; org-bearing adds always use multi.
- content := formatAddedMulti(requester)
- if len(subs) == 1 && len(req.Orgs) == 0 {
- onlyUser := userMap[subs[0].User.Account]
- content = formatAddedSingle(requester, &onlyUser)
- }
- sysMsg := model.Message{
- ID: idgen.MessageIDFromRequestID(seed, "addmembers"),
- RoomID: req.RoomID,
- UserID: requester.ID,
- UserAccount: requester.Account,
- Type: model.MessageTypeMembersAdded,
- Content: content,
- SysMsgData: sysMsgData,
- CreatedAt: acceptedAt,
- }
- msgEvt := model.MessageEvent{
- Event: model.EventCreated,
- Message: sysMsg,
- SiteID: room.SiteID,
- Timestamp: now.UnixMilli(),
- }
- msgEvtData, _ := json.Marshal(msgEvt)
- if err := h.publish(ctx, subject.MsgCanonicalCreated(room.SiteID), msgEvtData, sysMsg.ID); err != nil {
- return fmt.Errorf("publish add-members system message: %w", err)
+ membersAdded := model.MembersAdded{
+ Individuals: actualAccounts,
+ Orgs: req.Orgs,
+ Channels: req.Channels,
+ AddedUsersCount: len(subs),
+ }
+ sysMsgData, _ := json.Marshal(membersAdded)
+ seed := messageDedupSeed(ctx, "processAddMembers", req.RoomID,
+ fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp))
+ // Single form only for direct 1-user adds; org-bearing adds always use multi.
+ content := formatAddedMulti(requester)
+ if len(subs) == 1 && len(req.Orgs) == 0 {
+ onlyUser := userMap[subs[0].User.Account]
+ content = formatAddedSingle(requester, &onlyUser)
+ }
+ sysMsg := model.Message{
+ ID: idgen.MessageIDFromRequestID(seed, "addmembers"),
+ RoomID: req.RoomID,
+ UserID: requester.ID,
+ UserAccount: requester.Account,
+ Type: model.MessageTypeMembersAdded,
+ Content: content,
+ SysMsgData: sysMsgData,
+ CreatedAt: acceptedAt,
+ }
+ msgEvt := model.MessageEvent{
+ Event: model.EventCreated,
+ Message: sysMsg,
+ SiteID: room.SiteID,
+ Timestamp: now.UnixMilli(),
+ }
+ msgEvtData, _ := json.Marshal(msgEvt)
+ if err := h.publish(ctx, subject.MsgCanonicalCreated(room.SiteID), msgEvtData, sysMsg.ID); err != nil {
+ return fmt.Errorf("publish add-members system message: %w", err)
+ }
}
// 10. Outbox for cross-site members — batched by destination site
@@ -1132,7 +1251,6 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error
ID: req.RoomID,
Name: resolveRoomName(&req, roomType),
Type: roomType,
- CreatedBy: requester.ID,
SiteID: h.siteID,
CreatedAt: acceptedAt,
UpdatedAt: acceptedAt,
@@ -1156,28 +1274,14 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error
}
if err := h.store.CreateRoom(ctx, room); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- existing, fetchErr := h.store.GetRoom(ctx, room.ID)
- if fetchErr != nil {
- return fmt.Errorf("fetch on duplicate-key: %w", fetchErr)
- }
- // Replay equivalence: only treat the collision as a redelivery
- // when the existing room is identical on all immutable identity
- // fields (Type, SiteID, Name, CreatedBy). Any mismatch means the
- // same ID resolves to a different room — appending subscriptions
- // or system messages to it would corrupt unrelated state.
- if existing.Type != room.Type ||
- existing.SiteID != room.SiteID ||
- existing.Name != room.Name ||
- existing.CreatedBy != room.CreatedBy {
- return newPermanent("room ID collision (existing type=%s site=%s name=%q createdBy=%q; want %s/%s/%q/%q)",
- existing.Type, existing.SiteID, existing.Name, existing.CreatedBy,
- room.Type, room.SiteID, room.Name, room.CreatedBy)
- }
- room = existing
- } else {
+ if !mongo.IsDuplicateKeyError(err) {
return fmt.Errorf("create room: %w", err)
}
+ existing, err := h.reconcileRoomOnDuplicateKey(ctx, room)
+ if err != nil {
+ return fmt.Errorf("reconcile room on duplicate-key: %w", err)
+ }
+ room = existing
}
switch roomType {
@@ -1199,9 +1303,9 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error
return fmt.Errorf("re-read DM subs after write: %w", err)
}
subs = []*model.Subscription{requesterSub, counterpartSub}
- return h.finishCreateRoom(ctx, &req, room, requester, []model.User{*requester, *counterpart}, subs, requestID, now)
+ return h.finishCreateRoom(ctx, &req, room, requester, pair, []model.User{*requester, *counterpart}, subs, requestID, now)
case model.RoomTypeChannel:
- return h.processCreateRoomChannel(ctx, &req, room, requester, requestID, acceptedAt, now)
+ return h.processCreateRoomChannel(ctx, &req, room, requester, pair, requestID, acceptedAt, now)
default:
return newPermanent("unknown room type %q", roomType)
}
@@ -1221,7 +1325,7 @@ func determineRoomTypeFromPayload(req *model.CreateRoomRequest) model.RoomType {
return model.RoomTypeChannel
}
-func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error {
+func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, pair *roomkeystore.VersionedKeyPair, requestID string, acceptedAt, now time.Time) error {
// Pass requester.Account as excludeAccount so org-expansion can't re-
// introduce the requester (who joins separately as owner). Mirrors the
// room-service capacity-check exclusion exactly.
@@ -1301,10 +1405,10 @@ func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.Creat
// brings in an org will backfill existing accounts (including the owner)
// into `room_members`.
- return h.finishCreateRoom(ctx, req, room, requester, allUsers, subs, requestID, now)
+ return h.finishCreateRoom(ctx, req, room, requester, pair, allUsers, subs, requestID, now)
}
-func (h *Handler) finishCreateRoom(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, allUsers []model.User, subs []*model.Subscription, requestID string, now time.Time) error {
+func (h *Handler) finishCreateRoom(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, pair *roomkeystore.VersionedKeyPair, allUsers []model.User, subs []*model.Subscription, requestID string, now time.Time) error {
if err := h.store.ReconcileMemberCounts(ctx, room.ID); err != nil {
return fmt.Errorf("reconcile member counts: %w", err)
}
@@ -1405,7 +1509,7 @@ func (h *Handler) finishCreateRoom(ctx context.Context, req *model.CreateRoomReq
// subscriptions are durable but no member received the initial key event;
// NAK so JetStream retries the whole handler rather than persisting silent
// missing-key state.
- if err := h.buildAndFanOutRoomKey(ctx, room.ID, allUsers); err != nil {
+ if err := h.buildAndFanOutRoomKey(ctx, room.ID, pair, allUsers); err != nil {
return fmt.Errorf("room key fan-out (room %s): %w", room.ID, err)
}
@@ -1565,7 +1669,6 @@ func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.S
ID: roomID,
Name: "",
Type: req.RoomType,
- CreatedBy: requester.ID,
SiteID: h.siteID,
UserCount: userCount,
AppCount: appCount,
@@ -1578,21 +1681,19 @@ func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.S
if !mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("create room: %w", err)
}
- existing, fetchErr := h.store.GetRoom(ctx, room.ID)
- if fetchErr != nil {
- return nil, fmt.Errorf("fetch room on duplicate-key: %w", fetchErr)
- }
- if existing.Type != room.Type ||
- existing.SiteID != room.SiteID ||
- existing.Name != room.Name ||
- existing.CreatedBy != room.CreatedBy {
- slog.Error("sync DM: room ID collision",
- "roomID", room.ID,
- "existingType", existing.Type, "wantType", room.Type,
- "existingSiteID", existing.SiteID, "wantSiteID", room.SiteID,
- "existingCreatedBy", existing.CreatedBy, "wantCreatedBy", room.CreatedBy,
- "requestID", requestID)
- return nil, errRoomIDCollision
+ existing, reconcileErr := h.reconcileRoomOnDuplicateKey(ctx, room)
+ if reconcileErr != nil {
+ // Permanent errors from reconcile mean an unrecoverable collision; the
+ // sync-DM caller surfaces errRoomIDCollision verbatim, so map any
+ // permanent error onto that sentinel and keep the rich detail in the log.
+ if errors.Is(reconcileErr, errPermanent) {
+ slog.Error("sync DM: room ID collision",
+ "roomID", room.ID,
+ "requestID", requestID,
+ "error", reconcileErr)
+ return nil, errRoomIDCollision
+ }
+ return nil, fmt.Errorf("reconcile sync DM room on duplicate-key: %w", reconcileErr)
}
// Sync-path duplicate-key: forward-only — no UIDs/Accounts backfill on the existing room.
room = existing
@@ -1737,15 +1838,9 @@ func (h *Handler) fanOutRoomKeyToSurvivors(ctx context.Context, roomID string, p
h.fanOutKey(ctx, roomID, accounts, &evt)
}
-// buildAndFanOutRoomKey fetches the current key from Valkey, builds the RoomKeyEvent,
-// and fans it out to every room member account in users (local + remote).
-// NATS supercluster routes user-subjects to home sites.
-func (h *Handler) buildAndFanOutRoomKey(ctx context.Context, roomID string, users []model.User) error {
- pair, err := h.keyStore.Get(ctx, roomID)
- if err != nil {
- roomkeymetrics.ValkeyErrors.Add(ctx, 1, metric.WithAttributes(attribute.String("op", "Get")))
- return fmt.Errorf("get room key: %w", err)
- }
+// buildAndFanOutRoomKey publishes pair as a RoomKeyEvent to every account in users.
+// Caller owns the Get; nil pair returns a permanent error as a defensive guard.
+func (h *Handler) buildAndFanOutRoomKey(ctx context.Context, roomID string, pair *roomkeystore.VersionedKeyPair, users []model.User) error {
if pair == nil {
roomkeymetrics.KeyAbsentErrors.Add(ctx, 1)
return newPermanentAbsent("room key absent for %s", roomID)
diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go
index 8676cc248..752ab14e7 100644
--- a/room-worker/handler_test.go
+++ b/room-worker/handler_test.go
@@ -651,8 +651,11 @@ func TestHandler_ProcessAddMembers(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
- Return([]string{"bob", "charlie"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
+ Return([]AddMemberCandidate{
+ {Account: "bob", HasSubscription: false, HasIndividualRoomMember: false},
+ {Account: "charlie", HasSubscription: false, HasIndividualRoomMember: false},
+ }, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
{ID: "u3", Account: "charlie", SiteID: "site-b", EngName: "Charlie", ChineseName: "查"},
@@ -722,8 +725,11 @@ func TestHandler_ProcessAddMembers_PublishesSubscriptionUpdateBeforeRoomKey(t *t
h := NewHandler(store, "site-a", publish, testKeyStore, roomkeysender.NewSender(pub))
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
- Return([]string{"bob", "charlie"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
+ Return([]AddMemberCandidate{
+ {Account: "bob", HasSubscription: false, HasIndividualRoomMember: false},
+ {Account: "charlie", HasSubscription: false, HasIndividualRoomMember: false},
+ }, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
{ID: "u3", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"},
@@ -773,8 +779,8 @@ func TestHandler_ProcessAddMembers_HistoryAll(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1").
- Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
}, nil)
@@ -837,8 +843,10 @@ func TestHandler_ProcessAddMembers_RestrictedPropagatesPointer(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
- Return([]string{"bob", "charlie"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob", "charlie"}, "r1").
+ Return([]AddMemberCandidate{
+ {Account: "bob"}, {Account: "charlie"},
+ }, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
{ID: "u3", Account: "charlie", SiteID: "site-b", EngName: "Charlie", ChineseName: "查"},
@@ -901,8 +909,8 @@ func TestHandler_ProcessAddMembers_UnrestrictedOmitsFieldFromWire(t *testing.T)
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1").
- Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
}, nil)
@@ -940,8 +948,8 @@ func TestHandler_ProcessAddMembers_WithOrgs(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"eng"}, []string{"bob"}, "r1").
- Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"eng"}, []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
}, nil)
@@ -981,6 +989,53 @@ func TestHandler_ProcessAddMembers_WithOrgs(t *testing.T) {
require.NoError(t, err)
}
+// Backfill partial-subset guard: when an existing subscription points at an
+// account whose user document is gone (e.g. directory delete that didn't
+// cascade), FindUsersByAccounts returns fewer rows than requested. Without a
+// guard, the worker would silently commit room_members for the rows it got,
+// flip hadOrgsBefore=true via BulkCreateRoomMembers, and leave the stale
+// account with a sub but no individual room_members row that no future retry
+// can ever repair (subsequent deliveries see hadOrgsBefore=true and skip
+// backfill entirely). Fail hard with errPermanent so the operator sees it
+// before partial state is committed.
+func TestHandler_ProcessAddMembers_BackfillUserMissing(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ publish := func(_ context.Context, _ string, _ []byte, _ string) error { return nil }
+ h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
+
+ // Org-only add on a room with no prior orgs → backfill fires.
+ store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"eng"}, nil, "r1").
+ Return([]AddMemberCandidate{}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{
+ ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛",
+ }, nil)
+ // Existing subs: alice + ghost (ghost has no users document).
+ store.EXPECT().GetSubscriptionAccounts(gomock.Any(), "r1").Return([]string{"alice", "ghost"}, nil)
+ store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"alice", "ghost"}).Return([]model.User{
+ {ID: "u1", Account: "alice", SiteID: "site-a"},
+ }, nil)
+ // BulkCreateRoomMembers / ReconcileMemberCounts MUST NOT be called.
+
+ req := model.AddMembersRequest{
+ RoomID: "r1",
+ Orgs: []string{"eng"},
+ RequesterAccount: "alice",
+ Timestamp: 1000,
+ History: model.HistoryConfig{Mode: model.HistoryModeAll},
+ }
+ reqData, _ := json.Marshal(req)
+
+ err := h.processAddMembers(natsutil.WithRequestID(context.Background(), testRequestID), reqData)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, errPermanent)
+ assert.Contains(t, err.Error(), "ghost")
+ assert.Contains(t, err.Error(), "r1")
+}
+
// New permanent-error contract: when ListNewMembers resolves a candidate
// account that's no longer present in the users collection, processAddMembers
// must NOT silently materialize a smaller membership. It returns errPermanent
@@ -994,13 +1049,14 @@ func TestHandler_ProcessAddMembers_UserNotFound(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "ghost"}, "r1").
- Return([]string{"bob", "ghost"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob", "ghost"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}, {Account: "ghost"}}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "ghost"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a"},
}, nil)
- // BulkCreateSubscriptions / ReconcileMemberCounts / HasOrgRoomMembers
- // MUST NOT be called once a missing account is detected.
+ // BulkCreateSubscriptions / ReconcileMemberCounts MUST NOT be called once
+ // a missing account is detected.
req := model.AddMembersRequest{
RoomID: "r1",
@@ -1031,8 +1087,10 @@ func TestHandler_ProcessAddMembers_MultipleSiteOutbox(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"alice", "bob", "charlie"}, "r1").
- Return([]string{"alice", "bob", "charlie"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"alice", "bob", "charlie"}, "r1").
+ Return([]AddMemberCandidate{
+ {Account: "alice"}, {Account: "bob"}, {Account: "charlie"},
+ }, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"alice", "bob", "charlie"}).Return([]model.User{
{ID: "u1", Account: "alice", SiteID: "site-b", EngName: "Alice", ChineseName: "愛"},
{ID: "u2", Account: "bob", SiteID: "site-b", EngName: "Bob", ChineseName: "鮑"},
@@ -1093,9 +1151,9 @@ func TestHandler_ProcessRemoveMember_OwnerRemovesOrg(t *testing.T) {
// 3 org members: carol and dave have no individual membership, eve does
orgMembers := []OrgMemberStatus{
- {Account: "carol", SiteID: siteID, SectName: "Engineering", HasIndividualMembership: false},
- {Account: "dave", SiteID: siteID, SectName: "Engineering", HasIndividualMembership: false},
- {Account: "eve", SiteID: siteID, SectName: "Engineering", HasIndividualMembership: true},
+ {Account: "carol", SiteID: siteID, Name: "Engineering", HasIndividualMembership: false},
+ {Account: "dave", SiteID: siteID, Name: "Engineering", HasIndividualMembership: false},
+ {Account: "eve", SiteID: siteID, Name: "Engineering", HasIndividualMembership: true},
}
store.EXPECT().
@@ -1344,8 +1402,8 @@ func TestHandler_ProcessAddMembers_ExistingOrgsWritesIndividuals(t *testing.T) {
h := NewHandler(store, "site-a", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1").
- Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), nil, []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"},
}, nil)
@@ -1377,6 +1435,78 @@ func TestHandler_ProcessAddMembers_ExistingOrgsWritesIndividuals(t *testing.T) {
require.NoError(t, err)
}
+// TestHandler_ProcessAddMembers_OrgToIndividualUpgrade verifies the bug fix:
+// when an account already has a subscription (e.g. added earlier via org) but
+// no individual room_members row, an explicit add via req.Users must write the
+// missing individual row WITHOUT creating a duplicate subscription. It also
+// verifies that no MsgCanonicalCreated sys-msg and no MemberEvent are
+// published — the upgrade is a silent backfill, no membership state changed
+// for the room itself.
+func TestHandler_ProcessAddMembers_OrgToIndividualUpgrade(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "room-1"
+ requestID := idgen.GenerateRequestID()
+ ctx := natsutil.WithRequestID(context.Background(), requestID)
+
+ store.EXPECT().GetRoom(ctx, roomID).Return(&model.Room{
+ ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "Room 1",
+ }, nil)
+ // Alice has a subscription (added earlier via org) but no individual row.
+ // req.Orgs is omitted from the request → unmarshals to []string(nil), not []string{}.
+ store.EXPECT().ListAddMemberCandidates(ctx, []string(nil), []string{"alice"}, roomID).Return([]AddMemberCandidate{
+ {Account: "alice", HasSubscription: true, HasIndividualRoomMember: false},
+ }, nil)
+ store.EXPECT().HasOrgRoomMembers(ctx, roomID).Return(true, nil)
+ store.EXPECT().FindUsersByAccounts(ctx, []string{"alice"}).Return([]model.User{
+ {ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱丽丝", SiteID: "site-a"},
+ }, nil)
+ store.EXPECT().GetUser(ctx, "owner").Return(&model.User{
+ ID: "u_owner", Account: "owner", EngName: "Owner", ChineseName: "拥有者", SiteID: "site-a",
+ }, nil)
+ // CRITICAL: BulkCreateSubscriptions must NOT be called — alice already has a sub.
+ // BulkCreateRoomMembers must be called with exactly one individual row for alice.
+ store.EXPECT().BulkCreateRoomMembers(ctx, gomock.Any()).DoAndReturn(func(_ context.Context, members []*model.RoomMember) error {
+ require.Len(t, members, 1)
+ assert.Equal(t, model.RoomMemberIndividual, members[0].Member.Type)
+ assert.Equal(t, "alice", members[0].Member.Account)
+ assert.Equal(t, "u_alice", members[0].Member.ID)
+ return nil
+ })
+ store.EXPECT().ReconcileMemberCounts(ctx, roomID).Return(nil)
+
+ var published []publishedMsg
+ h := &Handler{
+ store: store, siteID: "site-a",
+ publish: func(_ context.Context, subj string, data []byte, _ string) error {
+ published = append(published, publishedMsg{subj: subj, data: data})
+ return nil
+ },
+ keyStore: testKeyStore, keySender: testKeySender,
+ }
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, Users: []string{"alice"}, RequesterAccount: "owner", RequesterID: "u_owner",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ err := h.processAddMembers(ctx, data)
+ require.NoError(t, err)
+
+ // No membership state changed for the room (only a silent individual-row
+ // backfill); the worker MUST NOT emit a sys-msg or member-add event that
+ // would render "added members to the channel" with an empty member list.
+ memberEventSubj := subject.RoomMemberEvent(roomID)
+ sysMsgSubj := subject.MsgCanonicalCreated("site-a")
+ for _, p := range published {
+ assert.NotEqual(t, memberEventSubj, p.subj,
+ "upgrade-only path must NOT publish MemberAddEvent")
+ assert.NotEqual(t, sysMsgSubj, p.subj,
+ "upgrade-only path must NOT publish a members_added sys-msg")
+ }
+}
+
// Bug 4: outbox publish failure must propagate (NAK), not be swallowed.
func TestHandler_ProcessRemoveIndividual_OutboxFailurePropagates(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -1436,7 +1566,7 @@ func TestHandler_ProcessRemoveOrg_OutboxFailurePropagates(t *testing.T) {
)
orgMembers := []OrgMemberStatus{
- {Account: "carol", SiteID: remoteSite, SectName: "Eng", HasIndividualMembership: false},
+ {Account: "carol", SiteID: remoteSite, Name: "Eng", HasIndividualMembership: false},
}
store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, orgID).Return(orgMembers, nil)
@@ -1480,7 +1610,8 @@ func TestHandler_processAddMembers_PublishesSuccessEventToRequesterSubject(t *te
h := NewHandler(store, "site1", publish, testKeyStore, testKeySender)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site1"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u2", Account: "bob", SiteID: "site1", EngName: "Bob", ChineseName: "鮑"},
}, nil)
@@ -1527,9 +1658,11 @@ func TestHandler_processAddMembers_PublishesFailureEventOnError(t *testing.T) {
}
h := NewHandler(store, "site1", publish, testKeyStore, testKeySender)
- // Mock store to fail on FindUsersByAccounts (first store operation after ListNewMembers)
+ // Mock store to fail on FindUsersByAccounts (first store operation after candidate resolution)
store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site1"}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").Return([]string{"bob"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return(nil, fmt.Errorf("database connection failed"))
ctx := natsutil.WithRequestID(context.Background(), testRequestID)
@@ -1639,8 +1772,12 @@ func setupAddMembersHappyPath(t *testing.T, mockStore *MockSubscriptionStore, ac
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-A",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return(accounts, nil)
+ candidates := make([]AddMemberCandidate, len(accounts))
+ for i, a := range accounts {
+ candidates[i] = AddMemberCandidate{Account: a}
+ }
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return(candidates, nil)
users := make([]model.User, len(accounts))
for i, a := range accounts {
users[i] = model.User{ID: "u_" + a, Account: a, SiteID: "site-A", EngName: "X", ChineseName: "X"}
@@ -1681,8 +1818,8 @@ func TestProcessAddMembers_PopulatesSubName(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-A",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"bob"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"},
}, nil)
@@ -1721,8 +1858,8 @@ func TestProcessAddMembers_HistoryNone_NoTimestamp(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-A",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"bob"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"},
}, nil)
@@ -1761,8 +1898,8 @@ func TestProcessAddMembers_NoHistoryConfig_LeavesNil(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-A",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"bob"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"},
}, nil)
@@ -1801,8 +1938,8 @@ func TestProcessAddMembers_OutboxCarriesRoomName(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-A",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"bob"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "bob"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{
{ID: "u_bob", Account: "bob", SiteID: "site-B", EngName: "Bob", ChineseName: "鲍勃"},
}, nil)
@@ -2694,10 +2831,13 @@ func TestHandleSyncCreateDM_RoomCollisionMismatch(t *testing.T) {
name string
existing model.Room
}{
- {"type mismatch", model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "", CreatedBy: "u-alice"}},
- {"siteID mismatch", model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-other", Name: "", CreatedBy: "u-alice"}},
- {"name mismatch", model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a", Name: "leak", CreatedBy: "u-alice"}},
- {"createdBy mismatch", model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a", Name: "", CreatedBy: "u-eve"}},
+ // Type and SiteID mismatches still trip the structural-compatibility guard.
+ // The structural check alone is sufficient: subscription writes are
+ // idempotent, so "requester not a member of existing room" is no longer
+ // a collision — it's a legitimate mid-write crash recovery case that
+ // the retry must complete by re-running BulkCreateSubscriptions.
+ {"type mismatch", model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: ""}},
+ {"siteID mismatch", model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-other", Name: ""}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -3013,7 +3153,10 @@ func TestHandleSyncCreateDM_BulkCreateSubsTransientError(t *testing.T) {
// On a CreateRoom dup-key with matching existing room (idempotent re-delivery),
// the handler must reuse existing.CreatedAt as acceptedAt — sub.JoinedAt and event
-// timestamps reflect the original creation, not retry wall-clock.
+// timestamps reflect the original creation, not retry wall-clock. The structural
+// check (type + site match) is all that's verified — BulkCreateSubscriptions is
+// idempotent under the unique (roomId, u.account) index, so re-running it on
+// retry is safe whether or not the requester already had a sub.
func TestHandleSyncCreateDM_IdempotentRecreate_UsesExistingCreatedAt(t *testing.T) {
h, store, _ := newSyncDMTestHandler(t)
@@ -3028,7 +3171,7 @@ func TestHandleSyncCreateDM_IdempotentRecreate_UsesExistingCreatedAt(t *testing.
store.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(dupErr)
store.EXPECT().GetRoom(gomock.Any(), gomock.Any()).Return(&model.Room{
ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a",
- Name: "", CreatedBy: "u-alice",
+ Name: "",
CreatedAt: originalCreatedAt, UpdatedAt: originalCreatedAt,
}, nil)
@@ -3073,7 +3216,7 @@ func TestHandleSyncCreateDM_BotDM_Recreate_PreservesExistingCreatedAt(t *testing
store.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(dupErr)
store.EXPECT().GetRoom(gomock.Any(), gomock.Any()).Return(&model.Room{
ID: roomID, Type: model.RoomTypeBotDM, SiteID: "site-a",
- Name: "", CreatedBy: "u-alice",
+ Name: "",
CreatedAt: originalCreatedAt, UpdatedAt: originalCreatedAt,
}, nil)
@@ -3285,9 +3428,6 @@ func TestProcessCreateRoom_Channel_PublishesCrossSiteMemberAdded(t *testing.T) {
// publishes a RoomKeyEvent for all members, including remote-site users. NATS supercluster routes
// user-subjects to home sites.
func TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite(t *testing.T) {
- ctrl := gomock.NewController(t)
- keyStore := NewMockRoomKeyStore(ctrl)
-
pub := &mockPublisher{}
sender := roomkeysender.NewSender(pub)
@@ -3297,10 +3437,8 @@ func TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite(t *testing.T
PrivateKey: []byte("priv"),
},
}
- keyStore.EXPECT().Get(gomock.Any(), "room-1").Return(keyPair, nil)
h := &Handler{
- keyStore: keyStore,
keySender: sender,
siteID: "site-A",
}
@@ -3311,7 +3449,7 @@ func TestBuildAndFanOutRoomKey_SendsToAllMembersIncludingRemoteSite(t *testing.T
{Account: "carol", SiteID: "site-B"}, // remote — also receives key
}
- err := h.buildAndFanOutRoomKey(context.Background(), "room-1", users)
+ err := h.buildAndFanOutRoomKey(context.Background(), "room-1", keyPair, users)
require.NoError(t, err)
assert.Equal(t, 3, pub.publishCount(), "all members including remote-site should receive key events")
}
@@ -3352,8 +3490,8 @@ func TestProcessAddMembers_FansOutKeyToNewAccountsOnly(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-a",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"charlie"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "charlie"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{
{ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"},
}, nil)
@@ -3394,8 +3532,8 @@ func TestProcessAddMembers_PermanentErrorWhenKeyMissing(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-a",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"charlie"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "charlie"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{
{ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"},
}, nil)
@@ -3430,8 +3568,8 @@ func TestProcessAddMembers_TransientErrorWhenValkeyFails(t *testing.T) {
mockStore.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{
ID: "r1", Name: "deal team", Type: model.RoomTypeChannel, SiteID: "site-a",
}, nil)
- mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
- Return([]string{"charlie"}, nil)
+ mockStore.EXPECT().ListAddMemberCandidates(gomock.Any(), gomock.Any(), gomock.Any(), "r1").
+ Return([]AddMemberCandidate{{Account: "charlie"}}, nil)
mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{
{ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"},
}, nil)
@@ -3473,31 +3611,6 @@ func TestProcessAddMembers_RejectsNonChannel(t *testing.T) {
// ---- Task 12: channel guard + version gate + fan-out to survivors ----
-// Skip-rotation guard: if Valkey is already past req.BaseKeyVersion, a previous
-// redelivery already rotated — current handler skips the rotation block (no key gen, no fan-out, no Rotate).
-func TestProcessRemoveMember_SkipsRotationWhenValkeyAlreadyAhead(t *testing.T) {
- ctrl := gomock.NewController(t)
- store := NewMockSubscriptionStore(ctrl)
- keyStore := NewMockRoomKeyStore(ctrl)
-
- // Valkey already at version 6; BaseKeyVersion = 5 means a prior delivery already rotated.
- keyStore.EXPECT().Get(gomock.Any(), "r1").Return(&roomkeystore.VersionedKeyPair{Version: 6}, nil)
-
- // Mongo work still happens (idempotent). No Rotate/Set should be called.
- store.EXPECT().GetUserWithMembership(gomock.Any(), "r1", "bob").
- Return(&UserWithMembership{User: model.User{ID: "u-bob", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}}, nil)
- store.EXPECT().DeleteRoomMember(gomock.Any(), "r1", model.RoomMemberIndividual, "u-bob").Return(nil)
- store.EXPECT().DeleteSubscription(gomock.Any(), "r1", "bob").Return(int64(1), nil)
- store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil)
- store.EXPECT().GetUser(gomock.Any(), "alice").
- Return(&model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
-
- h := NewHandler(store, "site-a", func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore, testKeySender)
- req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", BaseKeyVersion: 5, RoomType: model.RoomTypeChannel}
- data, _ := json.Marshal(req)
- require.NoError(t, h.processRemoveMember(natsutil.WithRequestID(context.Background(), "req-1"), data))
-}
-
func TestProcessRemoveMember_RejectsNonChannel(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockSubscriptionStore(ctrl)
@@ -3548,8 +3661,8 @@ func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID).
- Return([]string{"u_new"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o1"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u_new"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}).
Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
@@ -3577,6 +3690,72 @@ func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T
require.NoError(t, h.processAddMembers(ctx, data))
}
+// Backfill failure must fail-ha. JetStream redelivery is
+// safe because subs were written but the org row isn't until
+// BulkCreateRoomMembers, so hadOrgsBefore stays false on retry.
+func TestHandler_ProcessAddMembers_BackfillSubscriptionAccountsErrorFailsHard(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "r1"
+ store.EXPECT().GetRoom(gomock.Any(), roomID).
+ Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o1"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u_new"}}, nil)
+ store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}).
+ Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").
+ Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil)
+
+ store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil)
+ store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return(nil, fmt.Errorf("transient mongo error"))
+
+ h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender}
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice",
+ Orgs: []string{"o1"}, Timestamp: 1,
+ }
+ data, _ := json.Marshal(req)
+ ctx := natsutil.WithRequestID(context.Background(), testRequestID)
+ err := h.processAddMembers(ctx, data)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "get subscription accounts for backfill")
+}
+
+func TestHandler_ProcessAddMembers_BackfillFindUsersErrorFailsHard(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "r1"
+ store.EXPECT().GetRoom(gomock.Any(), roomID).
+ Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o1"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u_new"}}, nil)
+ store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}).
+ Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").
+ Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil)
+
+ store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil)
+ store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{"existing_user"}, nil)
+ store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"existing_user"}).Return(nil, fmt.Errorf("transient mongo error"))
+
+ h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender}
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice",
+ Orgs: []string{"o1"}, Timestamp: 1,
+ }
+ data, _ := json.Marshal(req)
+ ctx := natsutil.WithRequestID(context.Background(), testRequestID)
+ err := h.processAddMembers(ctx, data)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "find users for backfill")
+}
+
func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockSubscriptionStore(ctrl)
@@ -3584,8 +3763,8 @@ func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *test
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"o_new"}, []string(nil), roomID).
- Return([]string{"u_new"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o_new"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u_new"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}).
Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
@@ -3622,8 +3801,8 @@ func TestHandler_ProcessAddMembers_IndividualFilter_DirectAndOrgOverlap(t *testi
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string{"u1"}, roomID).
- Return([]string{"u1", "u2"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o1"}, []string{"u1"}, roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}, {Account: "u2"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}).
Return([]model.User{
{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"},
@@ -3676,8 +3855,8 @@ func TestHandler_ProcessAddMembers_IndividualFilter_OrgOnly(t *testing.T) {
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID).
- Return([]string{"u1"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"o1"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}).
Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
@@ -3746,7 +3925,9 @@ func TestHandler_ProcessCreateRoom_Channel_IndividualFilter(t *testing.T) {
ResolvedOrgs: []string{"o1"},
Timestamp: 1,
}
- require.NoError(t, h.processCreateRoomChannel(context.Background(), req, room, requester, "req-1", time.UnixMilli(1).UTC(), time.UnixMilli(2).UTC()))
+ pair, err := testKeyStore.Get(context.Background(), roomID)
+ require.NoError(t, err)
+ require.NoError(t, h.processCreateRoomChannel(context.Background(), req, room, requester, pair, "req-1", time.UnixMilli(1).UTC(), time.UnixMilli(2).UTC()))
var indivAccts []string
var orgIDs []string
@@ -3770,8 +3951,9 @@ func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) {
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID).
- Return([]string{"u1"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string(nil), []string{"u1"}, roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}}, nil)
+ store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}).
Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "missing-requester").Return(nil, ErrUserNotFound)
@@ -3829,8 +4011,8 @@ func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) {
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID).
- Return([]string{"u1"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string(nil), []string{"u1"}, roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}).
Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
@@ -3866,8 +4048,8 @@ func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) {
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1", "u2"}, roomID).
- Return([]string{"u1", "u2"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string(nil), []string{"u1", "u2"}, roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}, {Account: "u2"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}).
Return([]model.User{
{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"},
@@ -3933,6 +4115,7 @@ func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) {
store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil)
store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil)
store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().ListByRoom(gomock.Any(), roomID).Return([]model.Subscription{}, nil)
var published []publishedMsg
h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error {
@@ -3941,7 +4124,7 @@ func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) {
}, keyStore: testKeyStore, keySender: testKeySender}
req := model.RemoveMemberRequest{RoomID: roomID, Requester: "bob", Account: "bob", Timestamp: 1}
- require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil, false))
+ require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil))
sysMsg := findSysMsg(t, published, "site-a", "member_left")
assert.Equal(t, "bob", sysMsg.UserAccount)
@@ -3962,6 +4145,7 @@ func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) {
store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil)
store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil)
store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().ListByRoom(gomock.Any(), roomID).Return([]model.Subscription{}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
@@ -3972,7 +4156,7 @@ func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) {
}, keyStore: testKeyStore, keySender: testKeySender}
req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", Account: "bob", Timestamp: 1}
- require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil, false))
+ require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil))
sysMsg := findSysMsg(t, published, "site-a", "member_removed")
assert.Equal(t, "alice", sysMsg.UserAccount)
@@ -3989,8 +4173,8 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T
roomID := "r1"
store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1").
Return([]OrgMemberStatus{
- {Account: "u1", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true},
- {Account: "u2", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true},
+ {Account: "u1", SiteID: "site-a", Name: "Engineering", HasIndividualMembership: true},
+ {Account: "u2", SiteID: "site-a", Name: "Engineering", HasIndividualMembership: true},
}, nil)
// toRemove is empty → no DeleteSubscriptionsByAccounts call expected.
store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil)
@@ -4005,7 +4189,7 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T
}, keyStore: testKeyStore, keySender: testKeySender}
req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1}
- require.NoError(t, h.processRemoveOrg(context.Background(), &req, nil, false))
+ require.NoError(t, h.processRemoveOrg(context.Background(), &req, nil))
sysMsg := findSysMsg(t, published, "site-a", "member_removed")
assert.Equal(t, "alice", sysMsg.UserAccount)
@@ -4013,38 +4197,88 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T
assert.Equal(t, `"Engineering" has been removed from the channel`, sysMsg.Content)
}
-// D5: every member SectName empty → permanent error. The deferred
-// publishAsyncJobResult must also surface a sanitized error to the requester
-// so the client doesn't hang waiting for a reply.
+// D5: every member SectName empty → no permanent error. The orgID fallback
+// in displayOrg/CombineWithFallback guarantees a non-empty rendered string,
+// so the org-doc deletion and sys-message still go through. A slog.Warn is
+// emitted for visibility but the operation succeeds.
func TestHandler_ProcessRemoveOrg_AllSectNamesEmpty(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockSubscriptionStore(ctrl)
- store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), "r1", "o1").
+ roomID := "r1"
+ store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1").
Return([]OrgMemberStatus{
- {Account: "u1", SiteID: "site-a", SectName: "", HasIndividualMembership: false},
+ {Account: "u1", SiteID: "site-a", Name: "", TCName: "", IsDept: false, HasIndividualMembership: true},
}, nil)
- // No other mocks — permanent error must short-circuit before deletes/publishes.
+ // toRemove is empty (the member has individual membership) → no DeleteSubscriptionsByAccounts expected.
+ store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil)
+ store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").
+ Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
var published []publishedMsg
h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error {
published = append(published, publishedMsg{subj: subj, data: data})
return nil
}, keyStore: testKeyStore, keySender: testKeySender}
- req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", OrgID: "o1", Timestamp: 1}
+ req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1}
ctx := natsutil.WithRequestID(context.Background(), testRequestID)
- err := h.processRemoveOrg(ctx, &req, nil, false)
- require.Error(t, err)
- assert.ErrorIs(t, err, errPermanent)
+ require.NoError(t, h.processRemoveOrg(ctx, &req, nil))
- responses := userResponseFor(published, "alice")
- require.NotEmpty(t, responses, "permanent error must publish async-job error event")
- var result model.AsyncJobResult
- require.NoError(t, json.Unmarshal(responses[0].data, &result))
- assert.Equal(t, model.AsyncJobStatusError, result.Status)
- assert.Contains(t, result.Error, "missing SectName")
- assert.NotContains(t, result.Error, ": permanent")
+ sysMsg := findSysMsg(t, published, "site-a", "member_removed")
+ assert.Equal(t, `"o1" has been removed from the channel`, sysMsg.Content)
+ var payload model.MemberRemoved
+ require.NoError(t, json.Unmarshal(sysMsg.SysMsgData, &payload))
+ assert.Equal(t, "o1", payload.SectName)
+}
+
+// Multi-org overlap: when the user being removed is still covered by ANOTHER
+// org row in the same room (HasOtherOrgMembership == true), their subscription
+// must NOT be deleted. Reachable because this PR's dept-aware matching lets a
+// single user be the union of two org rows (one matching their sectId, one
+// matching their deptId).
+func TestHandler_ProcessRemoveOrg_OtherOrgCovers_PreservesSub(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "r1"
+ // alice has no individual row, but is still covered by another org row.
+ store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "X").
+ Return([]OrgMemberStatus{
+ {
+ Account: "alice", SiteID: "site-a",
+ Name: "Eng Sect", TCName: "工程組",
+ IsDept: false,
+ HasIndividualMembership: false,
+ HasOtherOrgMembership: true,
+ },
+ }, nil)
+ // MUST NOT be called — alice is still covered by the sibling org.
+ store.EXPECT().DeleteSubscriptionsByAccounts(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
+ // MUST NOT rotate — no survivors were displaced.
+ store.EXPECT().ListByRoom(gomock.Any(), gomock.Any()).Times(0)
+ // The X org row still gets deleted; the count gets reconciled.
+ store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "X").Return(nil)
+ store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice-req").
+ Return(&model.User{ID: "u_r", Account: "alice-req", SiteID: "site-a", EngName: "Req", ChineseName: "求"}, nil)
+
+ var published []publishedMsg
+ h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error {
+ published = append(published, publishedMsg{subj: subj, data: data})
+ return nil
+ }, keyStore: testKeyStore, keySender: testKeySender}
+ req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice-req", OrgID: "X", Timestamp: 1}
+ ctx := natsutil.WithRequestID(context.Background(), testRequestID)
+
+ require.NoError(t, h.processRemoveOrg(ctx, &req, nil))
+
+ // Sys-msg is still published — the org WAS removed — but RemovedUsersCount must be 0.
+ sysMsg := findSysMsg(t, published, "site-a", "member_removed")
+ var payload model.MemberRemoved
+ require.NoError(t, json.Unmarshal(sysMsg.SysMsgData, &payload))
+ assert.Equal(t, 0, payload.RemovedUsersCount, "no users were actually removed; siblings still cover them")
}
// F1: async DM create sets UIDs/Accounts sorted by UID, paired by index, on
@@ -4207,8 +4441,8 @@ func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *test
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
// req.Users is empty; org "eng" expands to one user "u1".
- store.EXPECT().ListNewMembers(gomock.Any(), []string{"eng"}, []string(nil), roomID).
- Return([]string{"u1"}, nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string{"eng"}, []string(nil), roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}}, nil)
store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}).
Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil)
store.EXPECT().GetUser(gomock.Any(), "alice").
@@ -4246,16 +4480,13 @@ func TestHandler_ProcessAddMembers_HasOrgRoomMembersError_FailsClosed(t *testing
roomID := "r1"
store.EXPECT().GetRoom(gomock.Any(), roomID).
Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil)
- store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID).
- Return([]string{"u1"}, nil)
- store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}).
- Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil)
- store.EXPECT().GetUser(gomock.Any(), "alice").
- Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
- store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil)
+ store.EXPECT().ListAddMemberCandidates(gomock.Any(), []string(nil), []string{"u1"}, roomID).
+ Return([]AddMemberCandidate{{Account: "u1"}}, nil)
store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).
Return(false, fmt.Errorf("transient mongo error"))
- // No BulkCreateRoomMembers / ReconcileMemberCounts — must short-circuit.
+ // No FindUsersByAccounts / GetUser / BulkCreateSubscriptions / BulkCreateRoomMembers /
+ // ReconcileMemberCounts — must short-circuit on the HasOrgRoomMembers error,
+ // which is now checked immediately after ListAddMemberCandidates.
h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender}
req := model.AddMembersRequest{
@@ -4357,3 +4588,81 @@ func TestHandler_RotateAndFanOut_ErrNoCurrentKey_UsesPredictedVersion(t *testing
err := h.rotateAndFanOut(context.Background(), "test-room", currentPair, nil)
require.NoError(t, err)
}
+
+// Dept-first tiebreak: on overlap (org membership reachable via both sect and
+// dept matches), the dept row wins the (Name, TCName) pick. When no dept row
+// matches, the first sect row wins. When members carry no names at all, the
+// orgID fallback in displayOrg keeps the sys-message non-empty (no permanent
+// error).
+func TestHandler_ProcessRemoveOrg_DeptFirstTiebreak(t *testing.T) {
+ cases := []struct {
+ name string
+ members []OrgMemberStatus
+ wantSect string // MemberRemoved.SectName
+ wantContent string // Message.Content
+ }{
+ {
+ name: "all sect users",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ {Account: "u2", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ },
+ wantSect: "Sect 組", wantContent: `"Sect 組" has been removed from the channel`,
+ },
+ {
+ name: "all dept users",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Dept", TCName: "部", IsDept: true, HasIndividualMembership: true},
+ },
+ wantSect: "Dept 部", wantContent: `"Dept 部" has been removed from the channel`,
+ },
+ {
+ name: "mixed — dept wins",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "Sect", TCName: "組", IsDept: false, HasIndividualMembership: true},
+ {Account: "u2", SiteID: "site-a", Name: "Dept", TCName: "部", IsDept: true, HasIndividualMembership: true},
+ },
+ wantSect: "Dept 部", wantContent: `"Dept 部" has been removed from the channel`,
+ },
+ {
+ name: "all names empty — fall back to orgID",
+ members: []OrgMemberStatus{
+ {Account: "u1", SiteID: "site-a", Name: "", TCName: "", IsDept: false, HasIndividualMembership: true},
+ },
+ wantSect: "o1", wantContent: `"o1" has been removed from the channel`,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ store := NewMockSubscriptionStore(ctrl)
+
+ roomID := "r1"
+ store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1").Return(tc.members, nil)
+ // toRemove is empty (all members have individual membership) → no DeleteSubscriptionsByAccounts expected.
+ store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil)
+ store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil)
+ store.EXPECT().GetUser(gomock.Any(), "alice").
+ Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil)
+
+ var published []publishedMsg
+ h := &Handler{
+ store: store, siteID: "site-a",
+ publish: func(_ context.Context, subj string, data []byte, _ string) error {
+ published = append(published, publishedMsg{subj: subj, data: data})
+ return nil
+ },
+ keyStore: testKeyStore, keySender: testKeySender,
+ }
+
+ req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1}
+ require.NoError(t, h.processRemoveOrg(context.Background(), &req, nil))
+
+ sysMsg := findSysMsg(t, published, "site-a", "member_removed")
+ assert.Equal(t, tc.wantContent, sysMsg.Content)
+ var payload model.MemberRemoved
+ require.NoError(t, json.Unmarshal(sysMsg.SysMsgData, &payload))
+ assert.Equal(t, tc.wantSect, payload.SectName)
+ })
+ }
+}
diff --git a/room-worker/integration_test.go b/room-worker/integration_test.go
index a13039d39..2947dbb4d 100644
--- a/room-worker/integration_test.go
+++ b/room-worker/integration_test.go
@@ -103,11 +103,8 @@ func TestMongoStore_Integration(t *testing.T) {
// Seed a room for ReconcileMemberCounts and GetRoom
db.Collection("rooms").InsertOne(ctx, model.Room{ID: "r1", Name: "general", UserCount: 1})
- // Test CreateSubscription
- sub := model.Subscription{ID: "s1", User: model.SubscriptionUser{ID: "u1"}, RoomID: "r1", Roles: []model.Role{model.RoleOwner}}
- if err := store.CreateSubscription(ctx, &sub); err != nil {
- t.Fatalf("CreateSubscription: %v", err)
- }
+ // Seed a subscription for ListByRoom / ReconcileMemberCounts.
+ mustInsertSub(t, db, &model.Subscription{ID: "s1", User: model.SubscriptionUser{ID: "u1"}, RoomID: "r1", Roles: []model.Role{model.RoleOwner}})
// Test ListByRoom
subs, err := store.ListByRoom(ctx, "r1")
@@ -212,6 +209,36 @@ func TestMongoStore_GetUserWithMembership_Integration(t *testing.T) {
})
}
+// TestMongoStore_GetUserWithMembership_DeptOnlyMatch_Integration pins the
+// dept-aware org-membership lookup: a user added via Orgs:["X"] whose deptId
+// is "X" (with no sectId match) must still report HasOrgMembership=true so the
+// remove flow preserves their subscription. Checking only sectId would miss
+// this case and cause the user's sub to be deleted even though they are still
+// org-attached via the dept.
+func TestMongoStore_GetUserWithMembership_DeptOnlyMatch_Integration(t *testing.T) {
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ ctx := context.Background()
+
+ // Alice has deptId="X" and NO sectId. The org row in room_members is keyed
+ // by member.id="X" — the dept-blind sectId-only lookup would miss it.
+ _, err := db.Collection("users").InsertOne(ctx, model.User{
+ ID: "u1", Account: "alice", SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering",
+ })
+ require.NoError(t, err)
+ _, err = db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: "rm-org", RoomID: "r1", Ts: time.Now().UTC(),
+ Member: model.RoomMemberEntry{ID: "X", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ result, err := store.GetUserWithMembership(ctx, "r1", "alice")
+ require.NoError(t, err)
+ assert.True(t, result.HasOrgMembership,
+ "deptId match must count as org membership — without it, removing the user would orphan an org-attached account")
+}
+
func TestMongoStore_GetOrgMembersWithIndividualStatus_Integration(t *testing.T) {
db := setupMongo(t)
store := NewMongoStore(db)
@@ -225,7 +252,7 @@ func TestMongoStore_GetOrgMembersWithIndividualStatus_Integration(t *testing.T)
_, err = db.Collection("room_members").InsertOne(ctx, model.RoomMember{
ID: "rm1", RoomID: "r1", Ts: time.Now().UTC(),
- Member: model.RoomMemberEntry{ID: "alice", Type: model.RoomMemberIndividual, Account: "alice"},
+ Member: model.RoomMemberEntry{ID: "u1", Type: model.RoomMemberIndividual, Account: "alice"},
})
require.NoError(t, err)
@@ -307,10 +334,10 @@ func TestMongoStore_DeleteSubscription_Integration(t *testing.T) {
store := NewMongoStore(db)
ctx := context.Background()
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: "alice"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: time.Now().UTC(),
- }))
+ })
deleted, err := store.DeleteSubscription(ctx, "r1", "alice")
require.NoError(t, err)
@@ -326,18 +353,18 @@ func TestMongoStore_DeleteSubscriptionsByAccounts_Integration(t *testing.T) {
store := NewMongoStore(db)
ctx := context.Background()
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ mustInsertSub(t, db, &model.Subscription{
ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: "alice"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: time.Now().UTC(),
- }))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ })
+ mustInsertSub(t, db, &model.Subscription{
ID: "s2", User: model.SubscriptionUser{ID: "u2", Account: "bob"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: time.Now().UTC(),
- }))
- require.NoError(t, store.CreateSubscription(ctx, &model.Subscription{
+ })
+ mustInsertSub(t, db, &model.Subscription{
ID: "s3", User: model.SubscriptionUser{ID: "u3", Account: "carol"},
RoomID: "r1", Roles: []model.Role{model.RoleMember}, JoinedAt: time.Now().UTC(),
- }))
+ })
deleted, err := store.DeleteSubscriptionsByAccounts(ctx, "r1", []string{"alice", "bob"})
require.NoError(t, err)
@@ -428,53 +455,6 @@ func mustInsertRoom(t *testing.T, db *mongo.Database, r *model.Room) {
require.NoError(t, err)
}
-func TestMongoStore_ListNewMembers_Integration(t *testing.T) {
- db := setupMongo(t)
- store := NewMongoStore(db)
- ctx := context.Background()
-
- users := []interface{}{
- model.User{ID: "u1", Account: "alice", SectID: "org1"},
- model.User{ID: "u2", Account: "bob", SectID: "org1"},
- model.User{ID: "u3", Account: "carol", SectID: "org2"},
- model.User{ID: "u4", Account: "dave"},
- model.User{ID: "u5", Account: "helper.bot", SectID: "org1"},
- }
- _, err := db.Collection("users").InsertMany(ctx, users)
- require.NoError(t, err)
-
- _, err = db.Collection("subscriptions").InsertOne(ctx, model.Subscription{
- ID: "s1",
- User: model.SubscriptionUser{ID: "u1", Account: "alice"},
- RoomID: "r1",
- })
- require.NoError(t, err)
-
- t.Run("merges org members and direct accounts, excludes already-subscribed and bots", func(t *testing.T) {
- got, err := store.ListNewMembers(ctx, []string{"org1"}, []string{"carol", "dave"}, "r1")
- require.NoError(t, err)
- assert.ElementsMatch(t, []string{"bob", "carol", "dave"}, got)
- })
-
- t.Run("empty inputs return nil", func(t *testing.T) {
- got, err := store.ListNewMembers(ctx, nil, nil, "r1")
- require.NoError(t, err)
- assert.Nil(t, got)
- })
-
- t.Run("orgIDs only", func(t *testing.T) {
- got, err := store.ListNewMembers(ctx, []string{"org2"}, nil, "r1")
- require.NoError(t, err)
- assert.ElementsMatch(t, []string{"carol"}, got)
- })
-
- t.Run("directAccounts only", func(t *testing.T) {
- got, err := store.ListNewMembers(ctx, nil, []string{"dave"}, "r1")
- require.NoError(t, err)
- assert.ElementsMatch(t, []string{"dave"}, got)
- })
-}
-
func TestReconcileMemberCountsSplitsBots(t *testing.T) {
ctx := context.Background()
db := setupMongo(t)
@@ -510,6 +490,47 @@ func mustInsertUser(t *testing.T, db *mongo.Database, u *model.User) {
require.NoError(t, err)
}
+func TestMongoStore_ListAddMemberCandidates_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ // Seed: alice (new), bob (sub only — bug scenario), carol (sub+IRM), dave (bot, excluded).
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_carol", Account: "carol", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_dave", Account: "dave.bot", SectID: "org-eng", SiteID: "site-a"})
+
+ const roomID = "room-1"
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_bob", Account: "bob"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_carol", Account: "carol"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "u_carol", Type: model.RoomMemberIndividual, Account: "carol"},
+ })
+ require.NoError(t, err)
+
+ got, err := store.ListAddMemberCandidates(ctx, []string{"org-eng"}, nil, roomID)
+ require.NoError(t, err)
+
+ byAccount := map[string]AddMemberCandidate{}
+ for _, c := range got {
+ byAccount[c.Account] = c
+ }
+ require.Len(t, byAccount, 3, "bot dave.bot must be excluded")
+ assert.Equal(t, AddMemberCandidate{Account: "alice", HasSubscription: false, HasIndividualRoomMember: false}, byAccount["alice"])
+ assert.Equal(t, AddMemberCandidate{Account: "bob", HasSubscription: true, HasIndividualRoomMember: false}, byAccount["bob"], "bug scenario: sub exists, IRM does not")
+ assert.Equal(t, AddMemberCandidate{Account: "carol", HasSubscription: true, HasIndividualRoomMember: true}, byAccount["carol"])
+}
+
// newIntegrationHandler creates a Handler wired to the given store and siteID with a no-op publish function.
func newIntegrationHandler(t *testing.T, store *MongoStore, siteID string) *Handler {
t.Helper()
@@ -599,9 +620,6 @@ func TestProcessCreateRoomDMPersistsTwoSubsAndZeroMembers(t *testing.T) {
room, err := store.GetRoom(ctx, roomID)
require.NoError(t, err)
assert.Equal(t, model.RoomTypeDM, room.Type)
- // CreatedBy is the requester's User.ID for every room type, including
- // DM/botDM (post-v2 cleanup; previously empty for DM/botDM).
- assert.Equal(t, "u_alice", room.CreatedBy)
assert.Equal(t, []string{"u_alice", "u_bob"}, room.UIDs, "DM participant uids persisted, sorted")
assert.Equal(t, []string{"alice", "bob"}, room.Accounts, "DM participant accounts persisted, paired by index with uids")
}
@@ -757,7 +775,7 @@ func TestProcessAddMembers_OutboxPerRemoteSite(t *testing.T) {
const roomName = "deal team"
mustInsertRoom(t, db, &model.Room{
ID: roomID, Name: roomName, Type: model.RoomTypeChannel,
- SiteID: "site-A", CreatedBy: "u_alice",
+ SiteID: "site-A",
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
// Owner sub.
@@ -878,7 +896,7 @@ func TestProcessAddMembers_PublishesLocalInbox_Integration(t *testing.T) {
const roomName = "federated-room"
mustInsertRoom(t, db, &model.Room{
ID: roomID, Name: roomName, Type: model.RoomTypeChannel,
- SiteID: "site-A", CreatedBy: "u_alice",
+ SiteID: "site-A",
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
mustInsertSub(t, db, &model.Subscription{
@@ -948,7 +966,7 @@ func TestProcessRemoveIndividual_PublishesLocalInbox_Integration(t *testing.T) {
roomID := idgen.GenerateID()
mustInsertRoom(t, db, &model.Room{
ID: roomID, Name: "fed-room", Type: model.RoomTypeChannel, SiteID: "site-A",
- CreatedBy: "u_alice", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
mustInsertSub(t, db, &model.Subscription{
ID: idgen.GenerateUUIDv7(), User: model.SubscriptionUser{ID: "u_bob", Account: "bob"},
@@ -1163,6 +1181,26 @@ func TestSyncCreateDM_CrossSite_OutboxPayloadConverges(t *testing.T) {
"replay must produce identical Nats-Msg-Id so broker dedup blocks duplicate cross-site events")
}
+func TestMongoStore_ListAddMemberCandidates_DeptMatching_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", DeptID: "dept-X", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", SectID: "dept-X", SiteID: "site-a"})
+
+ got, err := store.ListAddMemberCandidates(ctx, []string{"dept-X"}, nil, "room-1")
+ require.NoError(t, err)
+
+ accounts := map[string]bool{}
+ for _, c := range got {
+ accounts[c.Account] = true
+ }
+ assert.True(t, accounts["alice"], "alice matches by deptId")
+ assert.True(t, accounts["bob"], "bob matches by sectId (orgID coincides)")
+ assert.Len(t, got, 2)
+}
+
func setupValkey(t *testing.T) roomkeystore.RoomKeyStore {
t.Helper()
return roomkeystore.NewValkeyClusterStoreFromClient(testutil.StartValkeyCluster(t), time.Hour)
@@ -1305,7 +1343,7 @@ func TestProcessCreateRoom_BotDM_DoesNotUpsert_Integration(t *testing.T) {
mustInsertRoom(t, db, &model.Room{
ID: roomID, Type: model.RoomTypeBotDM, SiteID: "site-A",
- CreatedBy: "u_alice", CreatedAt: oldJoinedAt, UpdatedAt: oldJoinedAt,
+ CreatedAt: oldJoinedAt, UpdatedAt: oldJoinedAt,
UIDs: []string{"u_alice", "u_helper_bot"},
Accounts: []string{"alice", "helper.bot"},
})
@@ -1385,7 +1423,7 @@ func TestProcessCreateRoom_DM_DoesNotUpsert_Integration(t *testing.T) {
mustInsertRoom(t, db, &model.Room{
ID: roomID, Type: model.RoomTypeDM, SiteID: "site-A",
- CreatedBy: "u_alice", CreatedAt: oldJoinedAt, UpdatedAt: oldJoinedAt,
+ CreatedAt: oldJoinedAt, UpdatedAt: oldJoinedAt,
UIDs: []string{"u_alice", "u_bob"},
Accounts: []string{"alice", "bob"},
})
@@ -1429,4 +1467,278 @@ func TestProcessCreateRoom_DM_DoesNotUpsert_Integration(t *testing.T) {
"regular-DM path must NOT clear DisableNotification on re-create (insert-only contract)")
assert.True(t, got.JoinedAt.Equal(oldJoinedAt),
"regular-DM path must NOT refresh JoinedAt on re-create (insert-only contract)")
+
+ subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), subCount, "no duplicate subs after re-create")
+}
+
+// TestHandler_ProcessAddMembers_OrgToIndividualUpgrade_Integration verifies
+// the end-to-end bug fix: alice was previously added via an org expansion
+// (so she has a subscription and an org room_members row, but no individual
+// row). An explicit re-add via req.Users must (a) NOT create a duplicate
+// subscription and (b) DO write the missing individual row.
+func TestHandler_ProcessAddMembers_OrgToIndividualUpgrade_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ cap := &publishCapture{}
+ h := NewHandler(store, "site-a", cap.fn(), testKeyStore, testKeySender)
+
+ const roomID = "room-1"
+ mustInsertRoom(t, db, &model.Room{ID: roomID, Type: model.RoomTypeChannel, SiteID: "site-a", Name: "Room 1"})
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱丽丝", SectID: "org-eng", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_owner", Account: "owner", EngName: "Owner", ChineseName: "拥有者", SiteID: "site-a"})
+ // Pre-state: alice in via org-eng. Sub exists, org room_members row exists, no individual row.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel, Roles: []model.Role{model.RoleMember},
+ })
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "org-eng", Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+
+ req := model.AddMembersRequest{
+ RoomID: roomID, Users: []string{"alice"}, RequesterAccount: "owner", RequesterID: "u_owner",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ requestID := idgen.GenerateRequestID()
+ require.NoError(t, h.processAddMembers(natsutil.WithRequestID(ctx, requestID), data))
+
+ subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID, "u.account": "alice"})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), subCount, "no duplicate sub")
+
+ indivCount, err := db.Collection("room_members").CountDocuments(ctx, bson.M{
+ "rid": roomID, "member.type": "individual", "member.account": "alice",
+ })
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), indivCount, "individual room_members row written via upgrade path")
+}
+
+func TestMongoStore_GetOrgMembersWithIndividualStatus_DeptAndSect_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ mustInsertUser(t, db, &model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-a",
+ DeptID: "X", DeptName: "Engineering", DeptTCName: "工程部",
+ })
+ mustInsertUser(t, db, &model.User{
+ ID: "u_bob", Account: "bob", SiteID: "site-a",
+ SectID: "X", SectName: "Eng Sect", SectTCName: "工程組",
+ })
+
+ const roomID = "room-1"
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel,
+ })
+ // Bob has an individual room_members row (member.id = user._id).
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: "u_bob", Type: model.RoomMemberIndividual, Account: "bob"},
+ })
+ require.NoError(t, err)
+
+ got, err := store.GetOrgMembersWithIndividualStatus(ctx, roomID, "X")
+ require.NoError(t, err)
+
+ byAccount := map[string]OrgMemberStatus{}
+ for _, m := range got {
+ byAccount[m.Account] = m
+ }
+ require.Len(t, byAccount, 2)
+ assert.Equal(t, OrgMemberStatus{
+ Account: "alice", SiteID: "site-a",
+ Name: "Engineering", TCName: "工程部", IsDept: true, HasIndividualMembership: false,
+ }, byAccount["alice"])
+ assert.Equal(t, OrgMemberStatus{
+ Account: "bob", SiteID: "site-a",
+ Name: "Eng Sect", TCName: "工程組", IsDept: false, HasIndividualMembership: true,
+ }, byAccount["bob"])
+}
+
+// Multi-org overlap: alice's sectId matches one org row, her deptId matches
+// another. When asking for either org's members, the result MUST mark her
+// HasOtherOrgMembership=true so processRemoveOrg knows her subscription stays.
+// Without this, removing one of the two orgs would silently orphan her sub.
+func TestMongoStore_GetOrgMembersWithIndividualStatus_OtherOrgCovers_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+
+ const roomID = "room-1"
+ // alice: sectId="X", deptId="Y" — covered by both org rows simultaneously.
+ mustInsertUser(t, db, &model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-a",
+ SectID: "X", SectName: "Eng Sect", SectTCName: "工程組",
+ DeptID: "Y", DeptName: "Frontend", DeptTCName: "前端",
+ })
+ // carol: only sectId="X" — when X is removed she's not covered by anything else.
+ mustInsertUser(t, db, &model.User{
+ ID: "u_carol", Account: "carol", SiteID: "site-a",
+ SectID: "X", SectName: "Eng Sect", SectTCName: "工程組",
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ RoomType: model.RoomTypeChannel,
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_carol", Account: "carol"},
+ RoomType: model.RoomTypeChannel,
+ })
+ // Both X and Y are in the room as org members.
+ for _, orgID := range []string{"X", "Y"} {
+ _, err := db.Collection("room_members").InsertOne(ctx, model.RoomMember{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID,
+ Member: model.RoomMemberEntry{ID: orgID, Type: model.RoomMemberOrg},
+ })
+ require.NoError(t, err)
+ }
+
+ got, err := store.GetOrgMembersWithIndividualStatus(ctx, roomID, "X")
+ require.NoError(t, err)
+
+ byAccount := map[string]OrgMemberStatus{}
+ for _, m := range got {
+ byAccount[m.Account] = m
+ }
+ require.Len(t, byAccount, 2)
+ assert.True(t, byAccount["alice"].HasOtherOrgMembership,
+ "alice's deptId Y is also an org row in the room — she stays covered when X is removed")
+ assert.False(t, byAccount["carol"].HasOtherOrgMembership,
+ "carol has no other org coverage; removing X must drop her")
+}
+
+func TestHandler_ProcessCreateRoom_DMConcurrentByCounterpart_Integration(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ cap := &publishCapture{}
+ h := NewHandler(store, "site-a", cap.fn(), testKeyStore, testKeySender)
+
+ mustInsertUser(t, db, &model.User{ID: "u_alice", Account: "alice", EngName: "Alice", ChineseName: "爱", SiteID: "site-a"})
+ mustInsertUser(t, db, &model.User{ID: "u_bob", Account: "bob", EngName: "Bob", ChineseName: "鲍", SiteID: "site-a"})
+
+ // Pre-state: alice's worker already raced to create the DM. Room exists + both subs.
+ roomID := idgen.BuildDMRoomID("u_alice", "u_bob")
+ mustInsertRoom(t, db, &model.Room{
+ ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a", Name: "",
+ UIDs: []string{"u_alice", "u_bob"}, Accounts: []string{"alice", "bob"},
+ })
+ // Snapshot pre-existing sub IDs so we can verify the worker doesn't double-insert.
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_alice", Account: "alice"},
+ Name: "bob", RoomType: model.RoomTypeDM,
+ })
+ mustInsertSub(t, db, &model.Subscription{
+ ID: idgen.GenerateUUIDv7(), RoomID: roomID, SiteID: "site-a",
+ User: model.SubscriptionUser{ID: "u_bob", Account: "bob"},
+ Name: "alice", RoomType: model.RoomTypeDM,
+ })
+
+ type subID struct {
+ ID string `bson:"_id"`
+ }
+ var preSubs []subID
+ cursor, err := db.Collection("subscriptions").Find(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ require.NoError(t, cursor.All(ctx, &preSubs))
+ require.Len(t, preSubs, 2)
+ preIDs := map[string]bool{preSubs[0].ID: true, preSubs[1].ID: true}
+
+ // Bob's worker now processes Bob's canonical create event (Bob raced too).
+ req := model.CreateRoomRequest{
+ RoomID: roomID, Users: []string{"alice"},
+ RequesterID: "u_bob", RequesterAccount: "bob",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ }
+ data, _ := json.Marshal(req)
+ requestID := idgen.GenerateRequestID()
+ err = h.processCreateRoom(natsutil.WithRequestID(ctx, requestID), data)
+ require.NoError(t, err, "bob's race must NOT fail with collision; alice's worker already wrote both subs")
+
+ // One room, two subs, no replacements.
+ roomCount, err := db.Collection("rooms").CountDocuments(ctx, bson.M{"_id": roomID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), roomCount)
+
+ var postSubs []subID
+ cursor, err = db.Collection("subscriptions").Find(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ require.NoError(t, cursor.All(ctx, &postSubs))
+ require.Len(t, postSubs, 2, "no extra subscription docs")
+ for _, s := range postSubs {
+ assert.True(t, preIDs[s.ID], "subscription %s was replaced — worker should reuse pre-existing", s.ID)
+ }
+}
+
+// TestHandler_ProcessCreateRoom_RecoversFromMidWriteCrash exercises the
+// recovery path when a worker crashed AFTER CreateRoom succeeded but BEFORE
+// BulkCreateSubscriptions wrote any subs. JetStream redelivers the canonical
+// create event; the retry must find the existing room (structural match),
+// skip past the dup-key, and finish the unfinished subscription writes so
+// the room is no longer orphaned. Earlier behavior required the requester
+// to already have a sub — that turned mid-write crashes into permanent
+// failures and orphaned rooms.
+func TestHandler_ProcessCreateRoom_RecoversFromMidWriteCrash(t *testing.T) {
+ ctx := context.Background()
+ db := setupMongo(t)
+ store := NewMongoStore(db)
+ h := newIntegrationHandler(t, store, "site-A")
+
+ mustInsertUser(t, db, &model.User{
+ ID: "u_alice", Account: "alice", SiteID: "site-A",
+ EngName: "Alice", ChineseName: "爱丽丝",
+ })
+ mustInsertUser(t, db, &model.User{
+ ID: "u_bob", Account: "bob", SiteID: "site-A",
+ EngName: "Bob", ChineseName: "鲍勃",
+ })
+
+ // Pre-state: worker A wrote the room then crashed before BulkCreateSubscriptions.
+ // Room exists with ZERO subscriptions (the orphan that the previous reconcile
+ // helper turned into a permanent error and an unrecoverable DLQ entry).
+ const roomID = "r_midwrite"
+ const roomName = "deal team"
+ mustInsertRoom(t, db, &model.Room{
+ ID: roomID, Name: roomName, Type: model.RoomTypeChannel, SiteID: "site-A",
+ CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ })
+ subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ require.Equal(t, int64(0), subCount, "pre-state: orphaned room, no subs")
+
+ // JetStream redelivers; the retry must (a) not fail with a permanent error,
+ // (b) write the missing subs, and (c) leave room.UserCount reconciled.
+ const reqID = "0193abcd-0193-7abc-89ab-aaaa11111111"
+ body, err := json.Marshal(model.CreateRoomRequest{
+ RoomID: roomID, Name: roomName,
+ Users: []string{"bob"},
+ ResolvedUsers: []string{"bob"},
+ RequesterID: "u_alice",
+ RequesterAccount: "alice",
+ Timestamp: time.Now().UTC().UnixMilli(),
+ })
+ require.NoError(t, err)
+ require.NoError(t, h.processCreateRoom(natsutil.WithRequestID(ctx, reqID), body),
+ "mid-write crash recovery: retry must complete the unfinished writes, not return permanent")
+
+ subCount, err = db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), subCount, "owner + invitee subs written on retry")
+
+ room, err := store.GetRoom(ctx, roomID)
+ require.NoError(t, err)
+ assert.Equal(t, 2, room.UserCount, "ReconcileMemberCounts ran on retry")
}
diff --git a/room-worker/mock_store_test.go b/room-worker/mock_store_test.go
index a0adce1b2..3d5f71dcd 100644
--- a/room-worker/mock_store_test.go
+++ b/room-worker/mock_store_test.go
@@ -98,34 +98,6 @@ func (mr *MockSubscriptionStoreMockRecorder) CreateRoom(ctx, room any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoom", reflect.TypeOf((*MockSubscriptionStore)(nil).CreateRoom), ctx, room)
}
-// CreateRoomMember mocks base method.
-func (m *MockSubscriptionStore) CreateRoomMember(ctx context.Context, member *model.RoomMember) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateRoomMember", ctx, member)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// CreateRoomMember indicates an expected call of CreateRoomMember.
-func (mr *MockSubscriptionStoreMockRecorder) CreateRoomMember(ctx, member any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoomMember", reflect.TypeOf((*MockSubscriptionStore)(nil).CreateRoomMember), ctx, member)
-}
-
-// CreateSubscription mocks base method.
-func (m *MockSubscriptionStore) CreateSubscription(ctx context.Context, sub *model.Subscription) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateSubscription", ctx, sub)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// CreateSubscription indicates an expected call of CreateSubscription.
-func (mr *MockSubscriptionStoreMockRecorder) CreateSubscription(ctx, sub any) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSubscription", reflect.TypeOf((*MockSubscriptionStore)(nil).CreateSubscription), ctx, sub)
-}
-
// DeleteRoomMember mocks base method.
func (m *MockSubscriptionStore) DeleteRoomMember(ctx context.Context, roomID string, memberType model.RoomMemberType, memberID string) error {
m.ctrl.T.Helper()
@@ -306,34 +278,34 @@ func (mr *MockSubscriptionStoreMockRecorder) HasOrgRoomMembers(ctx, roomID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasOrgRoomMembers", reflect.TypeOf((*MockSubscriptionStore)(nil).HasOrgRoomMembers), ctx, roomID)
}
-// ListByRoom mocks base method.
-func (m *MockSubscriptionStore) ListByRoom(ctx context.Context, roomID string) ([]model.Subscription, error) {
+// ListAddMemberCandidates mocks base method.
+func (m *MockSubscriptionStore) ListAddMemberCandidates(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]AddMemberCandidate, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "ListByRoom", ctx, roomID)
- ret0, _ := ret[0].([]model.Subscription)
+ ret := m.ctrl.Call(m, "ListAddMemberCandidates", ctx, orgIDs, directAccounts, roomID)
+ ret0, _ := ret[0].([]AddMemberCandidate)
ret1, _ := ret[1].(error)
return ret0, ret1
}
-// ListByRoom indicates an expected call of ListByRoom.
-func (mr *MockSubscriptionStoreMockRecorder) ListByRoom(ctx, roomID any) *gomock.Call {
+// ListAddMemberCandidates indicates an expected call of ListAddMemberCandidates.
+func (mr *MockSubscriptionStoreMockRecorder) ListAddMemberCandidates(ctx, orgIDs, directAccounts, roomID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByRoom", reflect.TypeOf((*MockSubscriptionStore)(nil).ListByRoom), ctx, roomID)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAddMemberCandidates", reflect.TypeOf((*MockSubscriptionStore)(nil).ListAddMemberCandidates), ctx, orgIDs, directAccounts, roomID)
}
-// ListNewMembers mocks base method.
-func (m *MockSubscriptionStore) ListNewMembers(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]string, error) {
+// ListByRoom mocks base method.
+func (m *MockSubscriptionStore) ListByRoom(ctx context.Context, roomID string) ([]model.Subscription, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "ListNewMembers", ctx, orgIDs, directAccounts, roomID)
- ret0, _ := ret[0].([]string)
+ ret := m.ctrl.Call(m, "ListByRoom", ctx, roomID)
+ ret0, _ := ret[0].([]model.Subscription)
ret1, _ := ret[1].(error)
return ret0, ret1
}
-// ListNewMembers indicates an expected call of ListNewMembers.
-func (mr *MockSubscriptionStoreMockRecorder) ListNewMembers(ctx, orgIDs, directAccounts, roomID any) *gomock.Call {
+// ListByRoom indicates an expected call of ListByRoom.
+func (mr *MockSubscriptionStoreMockRecorder) ListByRoom(ctx, roomID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNewMembers", reflect.TypeOf((*MockSubscriptionStore)(nil).ListNewMembers), ctx, orgIDs, directAccounts, roomID)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByRoom", reflect.TypeOf((*MockSubscriptionStore)(nil).ListByRoom), ctx, roomID)
}
// ListNewMembersForNewRoom mocks base method.
diff --git a/room-worker/store.go b/room-worker/store.go
index 34cc1fdf7..33671ae17 100644
--- a/room-worker/store.go
+++ b/room-worker/store.go
@@ -28,13 +28,28 @@ type UserWithMembership struct {
type OrgMemberStatus struct {
Account string `bson:"account"`
SiteID string `bson:"siteId"`
- SectName string `bson:"sectName"`
+ Name string `bson:"name"`
+ TCName string `bson:"tcName"`
+ IsDept bool `bson:"isDept"`
HasIndividualMembership bool `bson:"hasIndividualMembership"`
+ // HasOtherOrgMembership is true when the user is still reachable via
+ // ANOTHER org row in the same room (one whose member.id matches the
+ // user's sectId or deptId), excluding the org being removed.
+ // processRemoveOrg uses this to avoid deleting subs of users who remain
+ // covered by a sibling org — relevant since this PR's dept-aware match
+ // makes the same user potentially reachable via two org rows
+ // concurrently (sectId-org + deptId-org).
+ HasOtherOrgMembership bool `bson:"hasOtherOrgMembership"`
+}
+
+// AddMemberCandidate is one element returned by ListAddMemberCandidates.
+type AddMemberCandidate struct {
+ Account string `bson:"account"`
+ HasSubscription bool `bson:"hasSubscription"`
+ HasIndividualRoomMember bool `bson:"hasIndividualRoomMember"`
}
type SubscriptionStore interface {
- // --- existing methods (invite flow) ---
- CreateSubscription(ctx context.Context, sub *model.Subscription) error
// BulkCreateSubscriptions upserts each sub keyed on (roomId, u.account)
// via $setOnInsert; collisions (e.g. JetStream redelivery) are a Mongo
// no-op so the persisted sub is preserved unchanged. Used by every
@@ -69,30 +84,25 @@ type SubscriptionStore interface {
DeleteRoomMember(ctx context.Context, roomID string, memberType model.RoomMemberType, memberID string) error
// --- add-member flow ---
- CreateRoomMember(ctx context.Context, member *model.RoomMember) error
BulkCreateRoomMembers(ctx context.Context, members []*model.RoomMember) error
FindUsersByAccounts(ctx context.Context, accounts []string) ([]model.User, error)
HasOrgRoomMembers(ctx context.Context, roomID string) (bool, error)
GetSubscriptionAccounts(ctx context.Context, roomID string) ([]string, error)
- // ListNewMembers returns the unique, non-bot accounts that would be added
- // to roomID for a given (orgIDs, directAccounts) tuple — i.e. the union
- // minus already-subscribed accounts. Used by processAddMembers to expand
- // the room-service-supplied (orgs, users) into the actual write list.
- // Delegates to pkg/pipelines.GetNewMembersPipeline + a $group/$addToSet
- // terminal stage.
- ListNewMembers(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]string, error)
+
+ // ListAddMemberCandidates: per-user {hasSub, hasIndividualRow} flags so the worker splits into needSub vs needIRM (org→individual upgrade).
+ ListAddMemberCandidates(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]AddMemberCandidate, error)
// CreateRoom inserts the room doc. Returns mongo.ErrDuplicateKey
// when the _id collides; the handler's idempotency logic handles
// matching-existing-room as success-on-redelivery.
CreateRoom(ctx context.Context, room *model.Room) error
- // ListNewMembersForNewRoom is the empty-roomID variant of
- // ListNewMembers — same dedup + bot filter, no "already-subscribed"
- // pruning since the room doesn't exist yet. excludeAccount drops one
- // account from the candidate set; create-channel passes the requester's
- // account so they aren't materialized as a regular member in addition to
- // being added separately as the owner.
+ // ListNewMembersForNewRoom is the empty-roomID variant of the
+ // ListAddMemberCandidates candidate resolution — same dedup + bot filter,
+ // no "already-subscribed" pruning since the room doesn't exist yet.
+ // excludeAccount drops one account from the candidate set; create-channel
+ // passes the requester's account so they aren't materialized as a regular
+ // member in addition to being added separately as the owner.
ListNewMembersForNewRoom(ctx context.Context, orgIDs, accounts []string, excludeAccount string) ([]string, error)
}
diff --git a/room-worker/store_mongo.go b/room-worker/store_mongo.go
index ebc0ccc45..d3ccc0a76 100644
--- a/room-worker/store_mongo.go
+++ b/room-worker/store_mongo.go
@@ -31,11 +31,6 @@ func NewMongoStore(db *mongo.Database) *MongoStore {
}
}
-func (s *MongoStore) CreateSubscription(ctx context.Context, sub *model.Subscription) error {
- _, err := s.subscriptions.InsertOne(ctx, sub)
- return err
-}
-
func (s *MongoStore) ListByRoom(ctx context.Context, roomID string) ([]model.Subscription, error) {
cursor, err := s.subscriptions.Find(ctx, bson.M{"roomId": roomID})
if err != nil {
@@ -206,14 +201,22 @@ func (s *MongoStore) RemoveRole(ctx context.Context, account, roomID string, rol
func (s *MongoStore) GetUserWithMembership(ctx context.Context, roomID, account string) (*UserWithMembership, error) {
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.M{"account": account}}},
+ // Dept-aware org-membership lookup: a user added via Orgs:["X"] may
+ // match the org by deptId only (no sectId), so the room_members row
+ // has member.id = deptId. Checking only sectId would miss that case
+ // and report HasOrgMembership=false, causing the remove flow to drop
+ // the user's subscription even though they are still org-attached.
{{Key: "$lookup", Value: bson.M{
"from": "room_members",
- "let": bson.M{"sectId": "$sectId"},
+ "let": bson.M{"sectId": "$sectId", "deptId": "$deptId"},
"pipeline": bson.A{
bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
bson.M{"$eq": bson.A{"$rid", roomID}},
bson.M{"$eq": bson.A{"$member.type", "org"}},
- bson.M{"$eq": bson.A{"$member.id", "$$sectId"}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$member.id", "$$sectId"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$deptId"}},
+ }},
}}}},
bson.M{"$limit": 1},
},
@@ -261,25 +264,64 @@ func (s *MongoStore) GetUserWithMembership(ctx context.Context, roomID, account
func (s *MongoStore) GetOrgMembersWithIndividualStatus(ctx context.Context, roomID, orgID string) ([]OrgMemberStatus, error) {
pipeline := mongo.Pipeline{
- {{Key: "$match", Value: bson.M{"sectId": orgID}}},
+ {{Key: "$match", Value: bson.M{"$or": bson.A{
+ bson.M{"sectId": orgID},
+ bson.M{"deptId": orgID},
+ }}}},
+ {{Key: "$addFields", Value: bson.M{
+ "isDept": bson.M{"$eq": bson.A{"$deptId", orgID}},
+ "name": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", orgID}}, "$deptName", "$sectName"}},
+ "tcName": bson.M{"$cond": bson.A{
+ bson.M{"$eq": bson.A{"$deptId", orgID}}, "$deptTCName", "$sectTCName"}},
+ }}},
{{Key: "$lookup", Value: bson.M{
"from": "room_members",
- "let": bson.M{"acct": "$account"},
+ "let": bson.M{"uid": "$_id"},
"pipeline": bson.A{
bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
bson.M{"$eq": bson.A{"$rid", roomID}},
bson.M{"$eq": bson.A{"$member.type", "individual"}},
- bson.M{"$eq": bson.A{"$member.account", "$$acct"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$uid"}},
}}}},
bson.M{"$limit": 1},
+ // Outer stage only reads $size — drop everything else.
+ bson.M{"$project": bson.M{"_id": 1}},
},
"as": "individualMembership",
}}},
+ // Sibling-org lookup: is there ANOTHER org row in the same room whose
+ // member.id matches this user's sectId or deptId (excluding the org
+ // being removed)? If yes, the user remains a member via that sibling
+ // even after the current org is dropped, so processRemoveOrg must NOT
+ // delete their subscription.
+ {{Key: "$lookup", Value: bson.M{
+ "from": "room_members",
+ "let": bson.M{"sectId": "$sectId", "deptId": "$deptId"},
+ "pipeline": bson.A{
+ bson.M{"$match": bson.M{"$expr": bson.M{"$and": bson.A{
+ bson.M{"$eq": bson.A{"$rid", roomID}},
+ bson.M{"$eq": bson.A{"$member.type", "org"}},
+ bson.M{"$ne": bson.A{"$member.id", orgID}},
+ bson.M{"$or": bson.A{
+ bson.M{"$eq": bson.A{"$member.id", "$$sectId"}},
+ bson.M{"$eq": bson.A{"$member.id", "$$deptId"}},
+ }},
+ }}}},
+ bson.M{"$limit": 1},
+ bson.M{"$project": bson.M{"_id": 1}},
+ },
+ "as": "otherOrgMembership",
+ }}},
{{Key: "$project", Value: bson.M{
+ "_id": 0,
"account": 1,
"siteId": 1,
- "sectName": 1,
+ "name": 1,
+ "tcName": 1,
+ "isDept": 1,
"hasIndividualMembership": bson.M{"$gt": bson.A{bson.M{"$size": "$individualMembership"}, 0}},
+ "hasOtherOrgMembership": bson.M{"$gt": bson.A{bson.M{"$size": "$otherOrgMembership"}, 0}},
}}},
}
cursor, err := s.users.Aggregate(ctx, pipeline)
@@ -340,16 +382,6 @@ func (s *MongoStore) BulkCreateSubscriptions(ctx context.Context, subs []*model.
return nil
}
-func (s *MongoStore) CreateRoomMember(ctx context.Context, member *model.RoomMember) error {
- if _, err := s.roomMembers.InsertOne(ctx, member); err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return nil
- }
- return fmt.Errorf("create room member for room %q: %w", member.RoomID, err)
- }
- return nil
-}
-
func (s *MongoStore) BulkCreateRoomMembers(ctx context.Context, members []*model.RoomMember) error {
if len(members) == 0 {
return nil
@@ -390,34 +422,29 @@ func (s *MongoStore) HasOrgRoomMembers(ctx context.Context, roomID string) (bool
return count > 0, nil
}
-func (s *MongoStore) ListNewMembers(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]string, error) {
+func (s *MongoStore) ListAddMemberCandidates(ctx context.Context, orgIDs, directAccounts []string, roomID string) ([]AddMemberCandidate, error) {
if len(orgIDs) == 0 && len(directAccounts) == 0 {
return nil, nil
}
-
- pipeline := pipelines.GetNewMembersPipeline(orgIDs, directAccounts, roomID, "")
- pipeline = append(pipeline, bson.M{
- "$group": bson.M{"_id": nil, "accounts": bson.M{"$addToSet": "$account"}},
- })
-
- cursor, err := s.users.Aggregate(ctx, pipeline)
+ pipeline, err := pipelines.GetAddMemberCandidatesPipeline(orgIDs, directAccounts, roomID, "")
if err != nil {
- return nil, fmt.Errorf("list new members: %w", err)
+ return nil, fmt.Errorf("build add-member candidates pipeline: %w", err)
}
- var results []struct {
- Accounts []string `bson:"accounts"`
- }
- if err := cursor.All(ctx, &results); err != nil {
- return nil, fmt.Errorf("decode list new members: %w", err)
+ cursor, err := s.users.Aggregate(ctx, pipeline)
+ if err != nil {
+ return nil, fmt.Errorf("aggregate add-member candidates: %w", err)
}
- if len(results) == 0 {
- return nil, nil
+ defer cursor.Close(ctx)
+ var out []AddMemberCandidate
+ if err := cursor.All(ctx, &out); err != nil {
+ return nil, fmt.Errorf("decode add-member candidates: %w", err)
}
- return results[0].Accounts, nil
+ return out, nil
}
func (s *MongoStore) GetSubscriptionAccounts(ctx context.Context, roomID string) ([]string, error) {
- cursor, err := s.subscriptions.Find(ctx, bson.M{"roomId": roomID})
+ cursor, err := s.subscriptions.Find(ctx, bson.M{"roomId": roomID},
+ options.Find().SetProjection(bson.M{"u.account": 1, "_id": 0}))
if err != nil {
return nil, fmt.Errorf("get subscription accounts for room %q: %w", roomID, err)
}
diff --git a/room-worker/sysmsg.go b/room-worker/sysmsg.go
index 576322acd..f6fe75734 100644
--- a/room-worker/sysmsg.go
+++ b/room-worker/sysmsg.go
@@ -1,27 +1,16 @@
package main
import (
- "strings"
-
+ "github.com/hmchangw/chat/pkg/displayfmt"
"github.com/hmchangw/chat/pkg/model"
)
-// displayName falls back to Account when both name fields are empty.
func displayName(u *model.User) string {
- eng := strings.TrimSpace(u.EngName)
- chinese := strings.TrimSpace(u.ChineseName)
- switch {
- case eng == "" && chinese == "":
- return u.Account
- case eng == "":
- return chinese
- case chinese == "":
- return eng
- case eng == chinese:
- return eng
- default:
- return eng + " " + chinese
- }
+ return displayfmt.CombineWithFallback(u.EngName, u.ChineseName, u.Account)
+}
+
+func displayOrg(name, tcName, orgID string) string {
+ return displayfmt.CombineWithFallback(name, tcName, orgID)
}
func quoted(name string) string {
@@ -40,8 +29,8 @@ func formatRemovedUser(user *model.User) string {
return quoted(displayName(user)) + " has been removed from the channel"
}
-func formatRemovedOrg(sectName string) string {
- return quoted(sectName) + " has been removed from the channel"
+func formatRemovedOrg(name, tcName, orgID string) string {
+ return quoted(displayOrg(name, tcName, orgID)) + " has been removed from the channel"
}
func formatLeft(user *model.User) string {
diff --git a/room-worker/sysmsg_test.go b/room-worker/sysmsg_test.go
index 3efc34b40..9064d41ae 100644
--- a/room-worker/sysmsg_test.go
+++ b/room-worker/sysmsg_test.go
@@ -27,7 +27,7 @@ func TestFormatRemovedUser(t *testing.T) {
}
func TestFormatRemovedOrg(t *testing.T) {
- got := formatRemovedOrg("Engineering")
+ got := formatRemovedOrg("Engineering", "", "orgX")
assert.Equal(t, `"Engineering" has been removed from the channel`, got)
}
@@ -97,3 +97,23 @@ func TestFormatAddedSingle_SingleNameSide(t *testing.T) {
)
assert.Equal(t, `"Alice" added "鮑勃" to the channel`, got)
}
+
+func TestDisplayName_DelegatesToCombineWithFallback(t *testing.T) {
+ u := &model.User{Account: "alice", EngName: "Alice", ChineseName: "爱丽丝"}
+ assert.Equal(t, "Alice 爱丽丝", displayName(u))
+
+ u2 := &model.User{Account: "bob"}
+ assert.Equal(t, "bob", displayName(u2), "both names empty → fallback to Account")
+}
+
+func TestDisplayOrg(t *testing.T) {
+ assert.Equal(t, "Eng 工程部", displayOrg("Eng", "工程部", "orgX"))
+ assert.Equal(t, "Eng", displayOrg("Eng", "", "orgX"))
+ assert.Equal(t, "工程部", displayOrg("", "工程部", "orgX"))
+ assert.Equal(t, "orgX", displayOrg("", "", "orgX"))
+}
+
+func TestFormatRemovedOrg_NewSignature(t *testing.T) {
+ assert.Equal(t, `"Eng 工程部" has been removed from the channel`, formatRemovedOrg("Eng", "工程部", "orgX"))
+ assert.Equal(t, `"orgX" has been removed from the channel`, formatRemovedOrg("", "", "orgX"))
+}