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")) +}