Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
80b8bb4
docs(spec): member-add improvements + DM dedup + CreatedBy/TargetUser…
claude May 19, 2026
d74f22d
docs(plan): implementation plan for member-add improvements + cleanups
claude May 19, 2026
7cec150
feat(pipelines): add GetCapacityCheckPipeline + GetAddMemberCandidate…
claude May 19, 2026
53d0a73
feat(model): add SectTCName, DeptID, DeptName, DeptTCName fields to User
claude May 19, 2026
0830109
feat(room-service): add (deptId, account) index for deptId-matching p…
claude May 19, 2026
982ecd6
feat(room-worker): add ListAddMemberCandidates with per-candidate flags
claude May 19, 2026
8c4037d
fix(chat-frontend): dedup reply navigates directly without summaries-…
claude May 19, 2026
b355d52
refactor(cassandra): drop unused target_user column from message schema
claude May 19, 2026
a4acacd
feat(pipelines): match deptId alongside sectId in candidate $or
claude May 19, 2026
304a646
feat(room-service): enrichment prefers dept on overlap with Go-side c…
claude May 19, 2026
417e396
fix(room-worker): create individual room_members row on org→individua…
claude May 19, 2026
03ca925
refactor(room-worker): OrgMemberStatus carries (Name,TCName,IsDept); …
claude May 19, 2026
e276653
feat(room-worker): processRemoveOrg uses dept-first tiebreak with dis…
claude May 19, 2026
fba650a
refactor(room-worker): drop Room.CreatedBy, extract reconcileRoomOnDu…
claude May 19, 2026
f2948da
test: add pipeline unit tests + strengthen DM concurrent test; polish…
claude May 19, 2026
c391578
fix: 4 review findings — reconcileRoomOnDuplicateKey idempotency, spu…
claude May 19, 2026
8d8ff8f
feat(room-service): ListOrgMembers matches sectId OR deptId
vjauhari-work May 20, 2026
6e8fe4a
chore(sast): pin runtime toolchain on sast-vuln target
vjauhari-work May 20, 2026
9f43bd1
feat(room-service): reject phantom org IDs and accounts at request time
vjauhari-work May 20, 2026
840d41d
refactor(room-worker): address PR #171 follow-up review findings
vjauhari-work May 20, 2026
1dae96a
fix: apply branch-review findings — panic→error, backfill fail-hard, …
vjauhari-work May 20, 2026
c96381f
chore(make): drop COMPOSE_BAKE/COMPOSE_PARALLEL_LIMIT from `make up`
vjauhari-work May 20, 2026
8c0072a
fix: resolve coderabbit findings on PR #212
vjauhari-work May 20, 2026
b622e57
docs(client-api): document users/orgs/channels on Create Room
claude May 21, 2026
f6aa582
refactor(room-service,room-worker): drop unused single-row CRUD store…
claude May 21, 2026
f9ffd62
refactor: drop shouldRotate guard and BaseKeyVersion per PR #171 review
claude May 21, 2026
c234aa9
docs: drop stale createdBy / createdByAccount from Room and Create Room
claude May 21, 2026
34c6e2b
fix: address remaining CodeRabbit findings on PR #212
claude May 21, 2026
9cf5c78
docs(client-api): widen userCount note for create-with-initial-members
claude May 21, 2026
64b9ff4
refactor(room-service): fold FindExistingOrgIDs dedupe into one pass
claude May 21, 2026
c217679
perf(room-service): collapse FindExistingOrgIDs into one Mongo round-…
claude May 21, 2026
aa90024
revert: drop $unionWith aggregation in FindExistingOrgIDs
claude May 21, 2026
0b5ccbe
perf(pipelines): project $lookup sub-results to {_id: 1}
claude May 21, 2026
6618d23
refactor(pipelines): drop GetCapacityCheckPipeline — duplicate of Get…
claude May 21, 2026
cd10c27
refactor(room-worker): single-pass dept-first tiebreak in processRemo…
claude May 21, 2026
8925c05
fix(room-worker): preserve sub when removed org has a sibling coverin…
claude May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion chat-frontend/src/api/fetchSidebarBuckets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ function subToRoom(sub: DMSubscription, fallbackSiteId: string): Room {
appCount: 0,
lastMsgId: sub.lastMsgId ?? '',
lastMsgAt: sub.lastMsgAt ?? undefined,
createdBy: '',
createdAt: '',
updatedAt: '',
}
Expand Down
1 change: 0 additions & 1 deletion chat-frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ export interface Room {
id: string
name: string
type: RoomType
createdBy: string
siteId: string
userCount: number
appCount: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CreateRoomDialog onClose={onClose} onCreated={onCreated} />)
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 () => {
Expand Down
1 change: 0 additions & 1 deletion docker-local/cassandra/init/10-table-messages_by_room.cql
Original file line number Diff line number Diff line change
Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down
1 change: 0 additions & 1 deletion docker-local/cassandra/init/13-table-messages_by_id.cql
Original file line number Diff line number Diff line change
Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down
7 changes: 5 additions & 2 deletions docker-local/compose.deps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 0 additions & 4 deletions docs/cassandra_message_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down Expand Up @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand All @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand All @@ -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<FROZEN<"Participant">>,
attachments LIST<BLOB>,
Expand Down
29 changes: 14 additions & 15 deletions docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelRef> | no | `channel` only. Other channels whose members should be copied in. Each entry is `{ "roomId": string, "siteId": string }`. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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": []
}
```

Expand All @@ -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. |
Expand All @@ -238,7 +241,6 @@ The created `Room` object.
"id": "01970a4f8c2d7c9aQ",
"name": "engineering-announcements",
"type": "channel",
"createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
"siteId": "siteA",
"userCount": 1,
"lastMsgId": "",
Expand All @@ -249,15 +251,15 @@ 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" }
```

##### 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).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

##### Triggered events — error path

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<Participant> | Optional. |
| `attachments` | string[] | Optional. Each entry is base64-encoded bytes. |
| `file` | object | Optional. `{id, name, type}`. |
Expand Down
Loading
Loading